diff --git a/build.gradle b/build.gradle index 088e8d81394..fc8b7c702e1 100644 --- a/build.gradle +++ b/build.gradle @@ -72,6 +72,29 @@ sourceSets { } } + + test { + java { + if (System.getenv("DOCKER_ENABLE_SECURITY") == "false") { + exclude "stirling/software/SPDF/config/security/**" + exclude "stirling/software/SPDF/controller/api/UserControllerTest.java" + exclude "stirling/software/SPDF/controller/api/DatabaseControllerTest.java" + exclude "stirling/software/SPDF/controller/web/AccountWebControllerTest.java" + exclude "stirling/software/SPDF/controller/web/DatabaseWebControllerTest.java" + exclude "stirling/software/SPDF/model/ApiKeyAuthenticationTokenTest.java" + exclude "stirling/software/SPDF/model/AttemptCounterTest.java" + exclude "stirling/software/SPDF/model/AuthorityTest.java" + exclude "stirling/software/SPDF/model/PersistentLoginTest.java" + exclude "stirling/software/SPDF/model/SessionEntityTest.java" + exclude "stirling/software/SPDF/model/UserTest.java" + exclude "stirling/software/SPDF/repository/**" + } + + if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") { + exclude "stirling/software/SPDF/UI/impl/**" + } + } + } } openApi { @@ -299,10 +322,12 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion" implementation "org.springframework.session:spring-session-core:$springBootVersion" + implementation "org.springframework:spring-jdbc:6.2.1" implementation 'com.unboundid.product.scim2:scim2-sdk-client:2.3.5' // Don't upgrade h2database runtimeOnly "com.h2database:h2:2.3.232" + runtimeOnly "org.postgresql:postgresql:42.7.4" constraints { implementation "org.opensaml:opensaml-core:$openSamlVersion" implementation "org.opensaml:opensaml-saml-api:$openSamlVersion" diff --git a/exampleYmlFiles/docker-compose-latest-fat-security-postgres.yml b/exampleYmlFiles/docker-compose-latest-fat-security-postgres.yml new file mode 100644 index 00000000000..2ad3d999a89 --- /dev/null +++ b/exampleYmlFiles/docker-compose-latest-fat-security-postgres.yml @@ -0,0 +1,63 @@ +services: + stirling-pdf: + container_name: Stirling-PDF-Security-Fat-Postgres + image: stirlingtools/stirling-pdf:latest-fat-postgres + deploy: + resources: + limits: + memory: 4G + depends_on: + - db + healthcheck: + test: [ "CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'" ] + interval: 5s + timeout: 10s + retries: 16 + ports: + - 8080:8080 + volumes: + - ./stirling/latest/data:/usr/share/tessdata:rw + - ./stirling/latest/config:/configs:rw + - ./stirling/latest/logs:/logs:rw + environment: + DOCKER_ENABLE_SECURITY: "true" + SECURITY_ENABLELOGIN: "false" + PUID: 1002 + PGID: 1002 + UMASK: "022" + SYSTEM_DEFAULTLOCALE: en-US + UI_APPNAME: Stirling-PDF + UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest-fat with Security and PostgreSQL + UI_APPNAMENAVBAR: Stirling-PDF Latest-fat-PostgreSQL + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "true" + SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: "true" + SYSTEM_DATASOURCE_CUSTOMDATABASEURL: "jdbc:postgresql://db:5432/stirling_pdf" + SYSTEM_DATASOURCE_USERNAME: "admin" + SYSTEM_DATASOURCE_PASSWORD: "stirling" + restart: on-failure:5 + + db: + image: 'postgres:17.2-alpine' + restart: on-failure:5 + container_name: db + ports: + - "5432:5432" + environment: + POSTGRES_DB: "stirling_pdf" + POSTGRES_USER: "admin" + POSTGRES_PASSWORD: "stirling" + shm_size: "512mb" + deploy: + resources: + limits: + memory: 512m + cpus: "0.5" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U admin stirling_pdf" ] + interval: 1s + timeout: 5s + retries: 10 + volumes: + - ./stirling/latest/data:/pgdata \ No newline at end of file diff --git a/exampleYmlFiles/docker-compose-latest-fat-security.yml b/exampleYmlFiles/docker-compose-latest-fat-security.yml index e46bd0e9f50..a8fedcb2430 100644 --- a/exampleYmlFiles/docker-compose-latest-fat-security.yml +++ b/exampleYmlFiles/docker-compose-latest-fat-security.yml @@ -14,9 +14,9 @@ services: ports: - 8080:8080 volumes: - - /stirling/latest/data:/usr/share/tessdata:rw - - /stirling/latest/config:/configs:rw - - /stirling/latest/logs:/logs:rw + - ./stirling/latest/data:/usr/share/tessdata:rw + - ./stirling/latest/config:/configs:rw + - ./stirling/latest/logs:/logs:rw environment: DOCKER_ENABLE_SECURITY: "true" SECURITY_ENABLELOGIN: "false" diff --git a/exampleYmlFiles/docker-compose-latest-security.yml b/exampleYmlFiles/docker-compose-latest-security.yml index d29c185d99c..5f8c977d142 100644 --- a/exampleYmlFiles/docker-compose-latest-security.yml +++ b/exampleYmlFiles/docker-compose-latest-security.yml @@ -14,9 +14,9 @@ services: ports: - "8080:8080" volumes: - - /stirling/latest/data:/usr/share/tessdata:rw - - /stirling/latest/config:/configs:rw - - /stirling/latest/logs:/logs:rw + - ./stirling/latest/data:/usr/share/tessdata:rw + - ./stirling/latest/config:/configs:rw + - ./stirling/latest/logs:/logs:rw environment: DOCKER_ENABLE_SECURITY: "true" SECURITY_ENABLELOGIN: "true" diff --git a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java index 108ee30204b..4636c892643 100644 --- a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java +++ b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java @@ -18,7 +18,7 @@ public class LicenseKeyChecker { private final ApplicationProperties applicationProperties; - private boolean enterpriseEnbaledResult = false; + private boolean enterpriseEnabledResult = false; @Autowired public LicenseKeyChecker( @@ -35,12 +35,12 @@ public void checkLicensePeriodically() { private void checkLicense() { if (!applicationProperties.getEnterpriseEdition().isEnabled()) { - enterpriseEnbaledResult = false; + enterpriseEnabledResult = false; } else { - enterpriseEnbaledResult = + enterpriseEnabledResult = licenseService.verifyLicense( applicationProperties.getEnterpriseEdition().getKey()); - if (enterpriseEnbaledResult) { + if (enterpriseEnabledResult) { log.info("License key is valid."); } else { log.info("License key is invalid."); @@ -55,6 +55,6 @@ public void updateLicenseKey(String newKey) throws IOException { } public boolean getEnterpriseEnabledResult() { - return enterpriseEnbaledResult; + return enterpriseEnabledResult; } } diff --git a/src/main/java/stirling/software/SPDF/SPdfApplication.java b/src/main/java/stirling/software/SPDF/SPDFApplication.java similarity index 90% rename from src/main/java/stirling/software/SPDF/SPdfApplication.java rename to src/main/java/stirling/software/SPDF/SPDFApplication.java index eabe017ce00..242a0bff697 100644 --- a/src/main/java/stirling/software/SPDF/SPdfApplication.java +++ b/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -28,13 +28,14 @@ import stirling.software.SPDF.config.InstallationPathConfig; import stirling.software.SPDF.model.ApplicationProperties; -@SpringBootApplication -@EnableScheduling @Slf4j -public class SPdfApplication { +@EnableScheduling +@SpringBootApplication +public class SPDFApplication { - private static String baseUrlStatic; private static String serverPortStatic; + private static String baseUrlStatic; + private final Environment env; private final ApplicationProperties applicationProperties; private final WebBrowser webBrowser; @@ -42,7 +43,7 @@ public class SPdfApplication { @Value("${baseUrl:http://localhost}") private String baseUrl; - public SPdfApplication( + public SPDFApplication( Environment env, ApplicationProperties applicationProperties, @Autowired(required = false) WebBrowser webBrowser) { @@ -51,33 +52,19 @@ public SPdfApplication( this.webBrowser = webBrowser; } - // Optionally keep this method if you want to provide a manual port-incrementation fallback. - private static String findAvailablePort(int startPort) { - int port = startPort; - while (!isPortAvailable(port)) { - port++; - } - return String.valueOf(port); - } - - private static boolean isPortAvailable(int port) { - try (ServerSocket socket = new ServerSocket(port)) { - return true; - } catch (IOException e) { - return false; - } - } - public static void main(String[] args) throws IOException, InterruptedException { - SpringApplication app = new SpringApplication(SPdfApplication.class); + SpringApplication app = new SpringApplication(SPDFApplication.class); + Properties props = new Properties(); + if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) { System.setProperty("java.awt.headless", "false"); app.setHeadless(false); props.put("java.awt.headless", "false"); props.put("spring.main.web-application-type", "servlet"); } - app.setAdditionalProfiles("default"); + + app.setAdditionalProfiles(getActiveProfile(args)); ConfigInitializer initializer = new ConfigInitializer(); try { @@ -85,8 +72,8 @@ public static void main(String[] args) throws IOException, InterruptedException } catch (IOException | URISyntaxException e) { log.error("Error initialising configuration", e); } - Map propertyFiles = new HashMap<>(); + // External config files log.info("Settings file: {}", InstallationPathConfig.getSettingsPath()); if (Files.exists(Paths.get(InstallationPathConfig.getSettingsPath()))) { @@ -98,6 +85,7 @@ public static void main(String[] args) throws IOException, InterruptedException "External configuration file '{}' does not exist.", InstallationPathConfig.getSettingsPath()); } + if (Files.exists(Paths.get(InstallationPathConfig.getCustomSettingsPath()))) { String existingLocation = propertyFiles.getOrDefault("spring.config.additional-location", ""); @@ -113,17 +101,21 @@ public static void main(String[] args) throws IOException, InterruptedException InstallationPathConfig.getCustomSettingsPath()); } Properties finalProps = new Properties(); + if (!propertyFiles.isEmpty()) { finalProps.putAll( Collections.singletonMap( "spring.config.additional-location", propertyFiles.get("spring.config.additional-location"))); } + if (!props.isEmpty()) { finalProps.putAll(props); } app.setDefaultProperties(finalProps); + app.run(args); + // Ensure directories are created try { Files.createDirectories(Path.of(InstallationPathConfig.getTemplatesPath())); @@ -131,32 +123,8 @@ public static void main(String[] args) throws IOException, InterruptedException } catch (Exception e) { log.error("Error creating directories: {}", e.getMessage()); } - printStartupLogs(); - } - - private static void printStartupLogs() { - log.info("Stirling-PDF Started."); - String url = baseUrlStatic + ":" + getStaticPort(); - log.info("Navigate to {}", url); - } - - public static String getStaticBaseUrl() { - return baseUrlStatic; - } - public static String getStaticPort() { - return serverPortStatic; - } - - @Value("${server.port:8080}") - public void setServerPortStatic(String port) { - if ("auto".equalsIgnoreCase(port)) { - // Use Spring Boot's automatic port assignment (server.port=0) - SPdfApplication.serverPortStatic = // This will let Spring Boot assign an available port - "0"; - } else { - SPdfApplication.serverPortStatic = port; - } + printStartupLogs(); } @PostConstruct @@ -189,6 +157,17 @@ public void init() { log.info("Running configs {}", applicationProperties.toString()); } + @Value("${server.port:8080}") + public void setServerPortStatic(String port) { + if ("auto".equalsIgnoreCase(port)) { + // Use Spring Boot's automatic port assignment (server.port=0) + SPDFApplication.serverPortStatic = + "0"; // This will let Spring Boot assign an available port + } else { + SPDFApplication.serverPortStatic = port; + } + } + @PreDestroy public void cleanup() { if (webBrowser != null) { @@ -196,10 +175,55 @@ public void cleanup() { } } + private static void printStartupLogs() { + log.info("Stirling-PDF Started."); + String url = baseUrlStatic + ":" + getStaticPort(); + log.info("Navigate to {}", url); + } + + private static String[] getActiveProfile(String[] args) { + if (args == null) { + return new String[] {"default"}; + } + + for (String arg : args) { + if (arg.contains("spring.profiles.active")) { + return arg.substring(args[0].indexOf('=') + 1).split(", "); + } + } + + return new String[] {"default"}; + } + + private static boolean isPortAvailable(int port) { + try (ServerSocket socket = new ServerSocket(port)) { + return true; + } catch (IOException e) { + return false; + } + } + + // Optionally keep this method if you want to provide a manual port-incrementation fallback. + private static String findAvailablePort(int startPort) { + int port = startPort; + while (!isPortAvailable(port)) { + port++; + } + return String.valueOf(port); + } + + public static String getStaticBaseUrl() { + return baseUrlStatic; + } + public String getNonStaticBaseUrl() { return baseUrlStatic; } + public static String getStaticPort() { + return serverPortStatic; + } + public String getNonStaticPort() { return serverPortStatic; } diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/DatabaseBackupInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/DatabaseBackupInterface.java deleted file mode 100644 index 9d0e094ac4c..00000000000 --- a/src/main/java/stirling/software/SPDF/config/interfaces/DatabaseBackupInterface.java +++ /dev/null @@ -1,17 +0,0 @@ -package stirling.software.SPDF.config.interfaces; - -import java.io.IOException; -import java.util.List; - -import stirling.software.SPDF.utils.FileInfo; - -public interface DatabaseBackupInterface { - - void exportDatabase() throws IOException; - - boolean importDatabase(); - - boolean hasBackup(); - - List getBackupList(); -} diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/DatabaseInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/DatabaseInterface.java new file mode 100644 index 00000000000..18fbf788308 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/interfaces/DatabaseInterface.java @@ -0,0 +1,17 @@ +package stirling.software.SPDF.config.interfaces; + +import java.sql.SQLException; +import java.util.List; + +import stirling.software.SPDF.model.provider.UnsupportedProviderException; +import stirling.software.SPDF.utils.FileInfo; + +public interface DatabaseInterface { + void exportDatabase() throws SQLException, UnsupportedProviderException; + + void importDatabase(); + + boolean hasBackup(); + + List getBackupList(); +} diff --git a/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java index 2d0228648c6..ffeb8d08cac 100644 --- a/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java @@ -20,7 +20,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.SPdfApplication; +import stirling.software.SPDF.SPDFApplication; import stirling.software.SPDF.config.security.saml2.CertificateUtils; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.SPDF.model.ApplicationProperties; @@ -110,7 +110,7 @@ private void getRedirect_saml2( // Construct URLs required for SAML configuration String serverUrl = - SPdfApplication.getStaticBaseUrl() + ":" + SPdfApplication.getStaticPort(); + SPDFApplication.getStaticBaseUrl() + ":" + SPDFApplication.getStaticPort(); String relyingPartyIdentifier = serverUrl + "/saml2/service-provider-metadata/" + registrationId; diff --git a/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java b/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java index 54f291a6bc0..c521d2caf68 100644 --- a/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java +++ b/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java @@ -1,49 +1,56 @@ package stirling.software.SPDF.config.security; -import java.io.IOException; +import java.sql.SQLException; import java.util.UUID; import org.springframework.stereotype.Component; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.config.interfaces.DatabaseBackupInterface; +import stirling.software.SPDF.config.interfaces.DatabaseInterface; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.Role; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; -@Component @Slf4j +@Component public class InitialSecuritySetup { private final UserService userService; private final ApplicationProperties applicationProperties; - private final DatabaseBackupInterface databaseBackupHelper; + private final DatabaseInterface databaseService; public InitialSecuritySetup( UserService userService, ApplicationProperties applicationProperties, - DatabaseBackupInterface databaseBackupHelper) { + DatabaseInterface databaseService) { this.userService = userService; this.applicationProperties = applicationProperties; - this.databaseBackupHelper = databaseBackupHelper; + this.databaseService = databaseService; } @PostConstruct - public void init() throws IllegalArgumentException, IOException { - if (databaseBackupHelper.hasBackup() && !userService.hasUsers()) { - databaseBackupHelper.importDatabase(); - } else if (!userService.hasUsers()) { - initializeAdminUser(); - } else { - databaseBackupHelper.exportDatabase(); + public void init() { + try { + if (databaseService.hasBackup()) { + databaseService.importDatabase(); + } + + if (!userService.hasUsers()) { + initializeAdminUser(); + } + userService.migrateOauth2ToSSO(); + initializeInternalApiUser(); + } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { + log.error("Failed to initialize security setup.", e); + System.exit(1); } - initializeInternalApiUser(); } - private void initializeAdminUser() throws IOException { + private void initializeAdminUser() throws SQLException, UnsupportedProviderException { String initialUsername = applicationProperties.getSecurity().getInitialLogin().getUsername(); String initialPassword = @@ -52,36 +59,34 @@ private void initializeAdminUser() throws IOException { && !initialUsername.isEmpty() && initialPassword != null && !initialPassword.isEmpty() - && !userService.findByUsernameIgnoreCase(initialUsername).isPresent()) { - try { - userService.saveUser(initialUsername, initialPassword, Role.ADMIN.getRoleId()); - log.info("Admin user created: " + initialUsername); - } catch (IllegalArgumentException e) { - log.error("Failed to initialize security setup", e); - System.exit(1); - } + && userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) { + + userService.saveUser(initialUsername, initialPassword, Role.ADMIN.getRoleId()); + log.info("Admin user created: {}", initialUsername); } else { createDefaultAdminUser(); } } - private void createDefaultAdminUser() throws IllegalArgumentException, IOException { + private void createDefaultAdminUser() throws SQLException, UnsupportedProviderException { String defaultUsername = "admin"; String defaultPassword = "stirling"; - if (!userService.findByUsernameIgnoreCase(defaultUsername).isPresent()) { + + if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) { userService.saveUser(defaultUsername, defaultPassword, Role.ADMIN.getRoleId(), true); - log.info("Default admin user created: " + defaultUsername); + log.info("Default admin user created: {}", defaultUsername); } } - private void initializeInternalApiUser() throws IllegalArgumentException, IOException { + private void initializeInternalApiUser() + throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) { userService.saveUser( Role.INTERNAL_API_USER.getRoleId(), UUID.randomUUID().toString(), Role.INTERNAL_API_USER.getRoleId()); userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId()); - log.info("Internal API user created: " + Role.INTERNAL_API_USER.getRoleId()); + log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId()); } userService.syncCustomApiUser(applicationProperties.getSecurity().getCustomGlobalAPIKey()); } diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index 5942f5fc4d6..2bc93352bb8 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.config.security; import java.io.IOException; +import java.sql.SQLException; import java.util.*; import java.util.stream.Collectors; @@ -20,11 +21,12 @@ import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.config.interfaces.DatabaseBackupInterface; +import stirling.software.SPDF.config.interfaces.DatabaseInterface; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.controller.api.pipeline.UserServiceInterface; import stirling.software.SPDF.model.*; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; import stirling.software.SPDF.repository.AuthorityRepository; import stirling.software.SPDF.repository.UserRepository; @@ -42,7 +44,7 @@ public class UserService implements UserServiceInterface { private final SessionPersistentRegistry sessionRegistry; - private final DatabaseBackupInterface databaseBackupHelper; + private final DatabaseInterface databaseService; private final ApplicationProperties applicationProperties; @@ -52,14 +54,14 @@ public UserService( PasswordEncoder passwordEncoder, MessageSource messageSource, SessionPersistentRegistry sessionRegistry, - DatabaseBackupInterface databaseBackupHelper, + DatabaseInterface databaseService, ApplicationProperties applicationProperties) { this.userRepository = userRepository; this.authorityRepository = authorityRepository; this.passwordEncoder = passwordEncoder; this.messageSource = messageSource; this.sessionRegistry = sessionRegistry; - this.databaseBackupHelper = databaseBackupHelper; + this.databaseService = databaseService; this.applicationProperties = applicationProperties; } @@ -76,7 +78,7 @@ public void migrateOauth2ToSSO() { // Handle OAUTH2 login and user auto creation. public boolean processSSOPostLogin(String username, boolean autoCreateUser) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return false; } @@ -163,12 +165,12 @@ public boolean validateApiKeyForUser(String username, String apiKey) { } public void saveUser(String username, AuthenticationType authenticationType) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { saveUser(username, authenticationType, Role.USER.getRoleId()); } public void saveUser(String username, AuthenticationType authenticationType, String role) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); } @@ -179,11 +181,11 @@ public void saveUser(String username, AuthenticationType authenticationType, Str user.addAuthority(new Authority(role, user)); user.setAuthenticationType(authenticationType); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } public void saveUser(String username, String password) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); } @@ -193,11 +195,11 @@ public void saveUser(String username, String password) user.setEnabled(true); user.setAuthenticationType(AuthenticationType.WEB); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } public void saveUser(String username, String password, String role, boolean firstLogin) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); } @@ -209,11 +211,11 @@ public void saveUser(String username, String password, String role, boolean firs user.setAuthenticationType(AuthenticationType.WEB); user.setFirstLogin(firstLogin); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } public void saveUser(String username, String password, String role) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { saveUser(username, password, role, false); } @@ -247,7 +249,7 @@ public boolean hasUsers() { } public void updateUserSettings(String username, Map updates) - throws IOException { + throws SQLException, UnsupportedProviderException { Optional userOpt = findByUsernameIgnoreCaseWithSettings(username); if (userOpt.isPresent()) { User user = userOpt.get(); @@ -259,7 +261,7 @@ public void updateUserSettings(String username, Map updates) settingsMap.putAll(updates); user.setSettings(settingsMap); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } } @@ -280,38 +282,45 @@ public Authority findRole(User user) { } public void changeUsername(User user, String newUsername) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, + IOException, + SQLException, + UnsupportedProviderException { if (!isUsernameValid(newUsername)) { throw new IllegalArgumentException(getInvalidUsernameMessage()); } user.setUsername(newUsername); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } - public void changePassword(User user, String newPassword) throws IOException { + public void changePassword(User user, String newPassword) + throws SQLException, UnsupportedProviderException { user.setPassword(passwordEncoder.encode(newPassword)); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } - public void changeFirstUse(User user, boolean firstUse) throws IOException { + public void changeFirstUse(User user, boolean firstUse) + throws SQLException, UnsupportedProviderException { user.setFirstLogin(firstUse); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } - public void changeRole(User user, String newRole) throws IOException { + public void changeRole(User user, String newRole) + throws SQLException, UnsupportedProviderException { Authority userAuthority = this.findRole(user); userAuthority.setAuthority(newRole); authorityRepository.save(userAuthority); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } - public void changeUserEnabled(User user, Boolean enbeled) throws IOException { + public void changeUserEnabled(User user, Boolean enbeled) + throws SQLException, UnsupportedProviderException { user.setEnabled(enbeled); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } public boolean isPasswordCorrect(User user, String currentPassword) { @@ -397,7 +406,8 @@ public String getCurrentUsername() { } @Transactional - public void syncCustomApiUser(String customApiKey) throws IOException { + public void syncCustomApiUser(String customApiKey) + throws SQLException, UnsupportedProviderException { if (customApiKey == null || customApiKey.trim().length() == 0) { return; } @@ -414,14 +424,14 @@ public void syncCustomApiUser(String customApiKey) throws IOException { user.setApiKey(customApiKey); user.addAuthority(new Authority(Role.INTERNAL_API_USER.getRoleId(), user)); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } else { // Update API key if it has changed User user = existingUser.get(); if (!customApiKey.equals(user.getApiKey())) { user.setApiKey(customApiKey); userRepository.save(user); - databaseBackupHelper.exportDatabase(); + databaseService.exportDatabase(); } } } diff --git a/src/main/java/stirling/software/SPDF/config/security/database/DatabaseBackupHelper.java b/src/main/java/stirling/software/SPDF/config/security/database/DatabaseBackupHelper.java deleted file mode 100644 index 5db05bed71b..00000000000 --- a/src/main/java/stirling/software/SPDF/config/security/database/DatabaseBackupHelper.java +++ /dev/null @@ -1,232 +0,0 @@ -package stirling.software.SPDF.config.security.database; - -import java.io.IOException; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.attribute.BasicFileAttributes; -import java.sql.*; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; - -import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.config.interfaces.DatabaseBackupInterface; -import stirling.software.SPDF.utils.FileInfo; - -@Slf4j -@Configuration -public class DatabaseBackupHelper implements DatabaseBackupInterface { - - @Value("${spring.datasource.url}") - private String url; - - @Value("${spring.datasource.username}") - private String databaseUsername; - - @Value("${spring.datasource.password}") - private String databasePassword; - - private Path backupPath = Paths.get("configs/db/backup/"); - - @Override - public boolean hasBackup() { - // Check if there is at least one backup - return !getBackupList().isEmpty(); - } - - @Override - public List getBackupList() { - // Check if the backup directory exists, and create it if it does not - ensureBackupDirectoryExists(); - - List backupFiles = new ArrayList<>(); - - // Read the backup directory and filter for files with the prefix "backup_" and suffix - // ".sql" - try (DirectoryStream stream = - Files.newDirectoryStream( - backupPath, - path -> - path.getFileName().toString().startsWith("backup_") - && path.getFileName().toString().endsWith(".sql"))) { - for (Path entry : stream) { - BasicFileAttributes attrs = Files.readAttributes(entry, BasicFileAttributes.class); - LocalDateTime modificationDate = - LocalDateTime.ofInstant( - attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault()); - LocalDateTime creationDate = - LocalDateTime.ofInstant( - attrs.creationTime().toInstant(), ZoneId.systemDefault()); - long fileSize = attrs.size(); - backupFiles.add( - new FileInfo( - entry.getFileName().toString(), - entry.toString(), - modificationDate, - fileSize, - creationDate)); - } - } catch (IOException e) { - log.error("Error reading backup directory: {}", e.getMessage(), e); - } - return backupFiles; - } - - // Imports a database backup from the specified file. - public boolean importDatabaseFromUI(String fileName) throws IOException { - return this.importDatabaseFromUI(getBackupFilePath(fileName)); - } - - // Imports a database backup from the specified path. - public boolean importDatabaseFromUI(Path tempTemplatePath) throws IOException { - boolean success = executeDatabaseScript(tempTemplatePath); - if (success) { - LocalDateTime dateNow = LocalDateTime.now(); - DateTimeFormatter myFormatObj = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); - Path insertOutputFilePath = - this.getBackupFilePath("backup_user_" + dateNow.format(myFormatObj) + ".sql"); - Files.copy(tempTemplatePath, insertOutputFilePath); - Files.deleteIfExists(tempTemplatePath); - } - return success; - } - - @Override - public boolean importDatabase() { - if (!this.hasBackup()) return false; - - List backupList = this.getBackupList(); - backupList.sort(Comparator.comparing(FileInfo::getModificationDate).reversed()); - - return executeDatabaseScript(Paths.get(backupList.get(0).getFilePath())); - } - - @Override - public void exportDatabase() throws IOException { - // Check if the backup directory exists, and create it if it does not - ensureBackupDirectoryExists(); - - // Filter and delete old backups if there are more than 5 - List filteredBackupList = - this.getBackupList().stream() - .filter(backup -> !backup.getFileName().startsWith("backup_user_")) - .collect(Collectors.toList()); - - if (filteredBackupList.size() > 5) { - filteredBackupList.sort( - Comparator.comparing( - p -> p.getFileName().substring(7, p.getFileName().length() - 4))); - Files.deleteIfExists(Paths.get(filteredBackupList.get(0).getFilePath())); - log.info("Deleted oldest backup: {}", filteredBackupList.get(0).getFileName()); - } - - LocalDateTime dateNow = LocalDateTime.now(); - DateTimeFormatter myFormatObj = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); - Path insertOutputFilePath = - this.getBackupFilePath("backup_" + dateNow.format(myFormatObj) + ".sql"); - String query = "SCRIPT SIMPLE COLUMNS DROP to ?;"; - - try (Connection conn = - DriverManager.getConnection(url, databaseUsername, databasePassword); - PreparedStatement stmt = conn.prepareStatement(query)) { - stmt.setString(1, insertOutputFilePath.toString()); - stmt.execute(); - log.info("Database export completed: {}", insertOutputFilePath); - } catch (SQLException e) { - log.error("Error during database export: {}", e.getMessage(), e); - } - } - - // Retrieves the H2 database version. - public String getH2Version() { - String version = "Unknown"; - try (Connection conn = - DriverManager.getConnection(url, databaseUsername, databasePassword)) { - try (Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery("SELECT H2VERSION() AS version")) { - if (rs.next()) { - version = rs.getString("version"); - log.info("H2 Database Version: {}", version); - } - } - } catch (SQLException e) { - log.error("Error retrieving H2 version: {}", e.getMessage(), e); - } - return version; - } - - // Deletes a backup file. - public boolean deleteBackupFile(String fileName) throws IOException { - if (!isValidFileName(fileName)) { - log.error("Invalid file name: {}", fileName); - return false; - } - Path filePath = this.getBackupFilePath(fileName); - if (Files.deleteIfExists(filePath)) { - log.info("Deleted backup file: {}", fileName); - return true; - } else { - log.error("File not found or could not be deleted: {}", fileName); - return false; - } - } - - // Gets the Path object for a given backup file name. - public Path getBackupFilePath(String fileName) { - Path filePath = Paths.get(backupPath.toString(), fileName).normalize(); - if (!filePath.startsWith(backupPath)) { - throw new SecurityException("Path traversal detected"); - } - return filePath; - } - - private boolean executeDatabaseScript(Path scriptPath) { - String query = "RUNSCRIPT from ?;"; - - try (Connection conn = - DriverManager.getConnection(url, databaseUsername, databasePassword); - PreparedStatement stmt = conn.prepareStatement(query)) { - stmt.setString(1, scriptPath.toString()); - stmt.execute(); - log.info("Database import completed: {}", scriptPath); - return true; - } catch (SQLException e) { - log.error("Error during database import: {}", e.getMessage(), e); - return false; - } - } - - private void ensureBackupDirectoryExists() { - if (Files.notExists(backupPath)) { - try { - Files.createDirectories(backupPath); - } catch (IOException e) { - log.error("Error creating directories: {}", e.getMessage()); - } - } - } - - private boolean isValidFileName(String fileName) { - // Check for invalid characters or sequences - return fileName != null - && !fileName.contains("..") - && !fileName.contains("/") - && !fileName.contains("\\") - && !fileName.contains(":") - && !fileName.contains("*") - && !fileName.contains("?") - && !fileName.contains("\"") - && !fileName.contains("<") - && !fileName.contains(">") - && !fileName.contains("|"); - } -} diff --git a/src/main/java/stirling/software/SPDF/config/security/database/DatabaseConfig.java b/src/main/java/stirling/software/SPDF/config/security/database/DatabaseConfig.java new file mode 100644 index 00000000000..338d34aa03e --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/database/DatabaseConfig.java @@ -0,0 +1,137 @@ +package stirling.software.SPDF.config.security.database; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.model.ApplicationProperties; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; + +@Slf4j +@Getter +@Configuration +public class DatabaseConfig { + + public static final String DATASOURCE_DEFAULT_URL = + "jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"; + public static final String DATASOURCE_URL_TEMPLATE = "jdbc:%s://%s:%4d/%s"; + public static final String DEFAULT_DRIVER = "org.h2.Driver"; + public static final String DEFAULT_USERNAME = "sa"; + public static final String POSTGRES_DRIVER = "org.postgresql.Driver"; + + private final ApplicationProperties applicationProperties; + private final boolean runningEE; + + public DatabaseConfig(ApplicationProperties applicationProperties, boolean runningEE) { + this.applicationProperties = applicationProperties; + this.runningEE = runningEE; + } + + /** + * Creates the DataSource for the connection to the DB. If useDefault + * is set to true, it will use the default H2 DB. If it is set to false + * , it will use the user's custom configuration set in the settings.yml. + * + * @return a DataSource using the configuration settings in the settings.yml + * @throws UnsupportedProviderException if the type of database selected is not supported + */ + @Bean + @Qualifier("dataSource") + public DataSource dataSource() throws UnsupportedProviderException { + DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create(); + + if (!runningEE) { + return useDefaultDataSource(dataSourceBuilder); + } + + ApplicationProperties.System system = applicationProperties.getSystem(); + ApplicationProperties.Datasource datasource = system.getDatasource(); + + if (!datasource.isEnableCustomDatabase()) { + return useDefaultDataSource(dataSourceBuilder); + } + + log.info("Using custom database configuration"); + + if (!datasource.getCustomDatabaseUrl().isBlank()) { + if (datasource.getCustomDatabaseUrl().contains("postgresql")) { + dataSourceBuilder.driverClassName(POSTGRES_DRIVER); + } + + dataSourceBuilder.url(datasource.getCustomDatabaseUrl()); + } else { + dataSourceBuilder.driverClassName(getDriverClassName(datasource.getType())); + dataSourceBuilder.url( + generateCustomDataSourceUrl( + datasource.getType(), + datasource.getHostName(), + datasource.getPort(), + datasource.getName())); + } + dataSourceBuilder.username(datasource.getUsername()); + dataSourceBuilder.password(datasource.getPassword()); + + return dataSourceBuilder.build(); + } + + private DataSource useDefaultDataSource(DataSourceBuilder dataSourceBuilder) { + log.info("Using default H2 database"); + + dataSourceBuilder.url(DATASOURCE_DEFAULT_URL); + dataSourceBuilder.username(DEFAULT_USERNAME); + + return dataSourceBuilder.build(); + } + + /** + * Generate the URL the DataSource will use to connect to the database + * + * @param dataSourceType the type of the database + * @param hostname the host name + * @param port the port number to use for the database + * @param dataSourceName the name the database to connect to + * @return the DataSource URL + */ + private String generateCustomDataSourceUrl( + String dataSourceType, String hostname, Integer port, String dataSourceName) { + return DATASOURCE_URL_TEMPLATE.formatted(dataSourceType, hostname, port, dataSourceName); + } + + /** + * Selects the database driver based on the type of database chosen. + * + * @param driverName the type of the driver (e.g. 'h2', 'postgresql') + * @return the fully qualified driver for the database chosen + * @throws UnsupportedProviderException when an unsupported database is selected + */ + private String getDriverClassName(String driverName) throws UnsupportedProviderException { + try { + ApplicationProperties.Driver driver = + ApplicationProperties.Driver.valueOf(driverName.toUpperCase()); + + switch (driver) { + case H2 -> { + log.debug("H2 driver selected"); + return DEFAULT_DRIVER; + } + case POSTGRESQL -> { + log.debug("Postgres driver selected"); + return POSTGRES_DRIVER; + } + default -> { + log.warn("{} driver selected", driverName); + throw new UnsupportedProviderException( + driverName + " is not currently supported"); + } + } + } catch (IllegalArgumentException e) { + log.warn("Unknown driver: {}", driverName); + throw new UnsupportedProviderException(driverName + " is not currently supported"); + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/database/DatabaseService.java b/src/main/java/stirling/software/SPDF/config/security/database/DatabaseService.java new file mode 100644 index 00000000000..b0a04702164 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/database/DatabaseService.java @@ -0,0 +1,301 @@ +package stirling.software.SPDF.config.security.database; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import javax.sql.DataSource; + +import org.springframework.jdbc.datasource.init.CannotReadScriptException; +import org.springframework.jdbc.datasource.init.ScriptException; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.DatabaseInterface; +import stirling.software.SPDF.model.ApplicationProperties; +import stirling.software.SPDF.model.exception.BackupNotFoundException; +import stirling.software.SPDF.utils.FileInfo; + +@Slf4j +@Service +public class DatabaseService implements DatabaseInterface { + + public static final String BACKUP_PREFIX = "backup_"; + public static final String SQL_SUFFIX = ".sql"; + private static final String BACKUP_DIR = "configs/db/backup/"; + + private final ApplicationProperties applicationProperties; + private final DataSource dataSource; + + public DatabaseService(ApplicationProperties applicationProperties, DataSource dataSource) { + this.applicationProperties = applicationProperties; + this.dataSource = dataSource; + } + + /** + * Checks if there is at least one backup. First checks if the directory exists, then checks if + * there are backup scripts within the directory + * + * @return true if there are backup scripts, false if there are not + */ + @Override + public boolean hasBackup() { + Path filePath = Paths.get(BACKUP_DIR); + + if (Files.exists(filePath)) { + return !getBackupList().isEmpty(); + } + + return false; + } + + /** + * Read the backup directory and filter for files with the prefix "backup_" and suffix ".sql" + * + * @return a List of backup files + */ + @Override + public List getBackupList() { + List backupFiles = new ArrayList<>(); + + if (isH2Database()) { + Path backupPath = Paths.get(BACKUP_DIR); + + try (DirectoryStream stream = + Files.newDirectoryStream( + backupPath, + path -> + path.getFileName().toString().startsWith(BACKUP_PREFIX) + && path.getFileName() + .toString() + .endsWith(SQL_SUFFIX))) { + for (Path entry : stream) { + BasicFileAttributes attrs = + Files.readAttributes(entry, BasicFileAttributes.class); + LocalDateTime modificationDate = + LocalDateTime.ofInstant( + attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault()); + LocalDateTime creationDate = + LocalDateTime.ofInstant( + attrs.creationTime().toInstant(), ZoneId.systemDefault()); + long fileSize = attrs.size(); + backupFiles.add( + new FileInfo( + entry.getFileName().toString(), + entry.toString(), + modificationDate, + fileSize, + creationDate)); + } + } catch (IOException e) { + log.error("Error reading backup directory: {}", e.getMessage(), e); + } + } + + return backupFiles; + } + + @Override + public void importDatabase() { + if (!hasBackup()) throw new BackupNotFoundException("No backup scripts were found."); + + List backupList = this.getBackupList(); + backupList.sort(Comparator.comparing(FileInfo::getModificationDate).reversed()); + + Path latestExport = Paths.get(backupList.get(0).getFilePath()); + + executeDatabaseScript(latestExport); + } + + /** Imports a database backup from the specified file. */ + public boolean importDatabaseFromUI(String fileName) { + try { + importDatabaseFromUI(getBackupFilePath(fileName)); + return true; + } catch (IOException e) { + log.error( + "Error importing database from file: {}, message: {}", + fileName, + e.getMessage(), + e.getCause()); + return false; + } + } + + /** Imports a database backup from the specified path. */ + public boolean importDatabaseFromUI(Path tempTemplatePath) throws IOException { + executeDatabaseScript(tempTemplatePath); + LocalDateTime dateNow = LocalDateTime.now(); + DateTimeFormatter myFormatObj = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); + Path insertOutputFilePath = + this.getBackupFilePath( + BACKUP_PREFIX + "user_" + dateNow.format(myFormatObj) + SQL_SUFFIX); + Files.copy(tempTemplatePath, insertOutputFilePath); + Files.deleteIfExists(tempTemplatePath); + return true; + } + + @Override + public void exportDatabase() { + List filteredBackupList = + this.getBackupList().stream() + .filter(backup -> !backup.getFileName().startsWith(BACKUP_PREFIX + "user_")) + .collect(Collectors.toList()); + + if (filteredBackupList.size() > 5) { + deleteOldestBackup(filteredBackupList); + } + + LocalDateTime dateNow = LocalDateTime.now(); + DateTimeFormatter myFormatObj = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); + Path insertOutputFilePath = + this.getBackupFilePath(BACKUP_PREFIX + dateNow.format(myFormatObj) + SQL_SUFFIX); + + if (isH2Database()) { + String query = "SCRIPT SIMPLE COLUMNS DROP to ?;"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(query)) { + stmt.setString(1, insertOutputFilePath.toString()); + stmt.execute(); + } catch (SQLException e) { + log.error("Error during database export: {}", e.getMessage(), e); + } catch (CannotReadScriptException e) { + log.error("Error during database export: File {} not found", insertOutputFilePath); + } + } + + log.info("Database export completed: {}", insertOutputFilePath); + } + + private static void deleteOldestBackup(List filteredBackupList) { + try { + filteredBackupList.sort( + Comparator.comparing( + p -> p.getFileName().substring(7, p.getFileName().length() - 4))); + + FileInfo oldestFile = filteredBackupList.get(0); + Files.deleteIfExists(Paths.get(oldestFile.getFilePath())); + log.info("Deleted oldest backup: {}", oldestFile.getFileName()); + } catch (IOException e) { + log.error("Unable to delete oldest backup, message: {}", e.getMessage(), e); + } + } + + /** + * Retrieves the H2 database version. + * + * @return String of the H2 version + */ + public String getH2Version() { + String version = "Unknown"; + + if (isH2Database()) { + try (Connection conn = dataSource.getConnection()) { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT H2VERSION() AS version")) { + if (rs.next()) { + version = rs.getString("version"); + log.info("H2 Database Version: {}", version); + } + } + } catch (SQLException e) { + log.error("Error retrieving H2 version: {}", e.getMessage(), e); + } + } + + return version; + } + + private boolean isH2Database() { + ApplicationProperties.Datasource datasource = + applicationProperties.getSystem().getDatasource(); + return !datasource.isEnableCustomDatabase() + || datasource.getType().equals(ApplicationProperties.Driver.H2.name()); + } + + /** + * Deletes a backup file. + * + * @return true if successful, false if not + */ + public boolean deleteBackupFile(String fileName) throws IOException { + if (!isValidFileName(fileName)) { + log.error("Invalid file name: {}", fileName); + return false; + } + Path filePath = this.getBackupFilePath(fileName); + if (Files.deleteIfExists(filePath)) { + log.info("Deleted backup file: {}", fileName); + return true; + } else { + log.error("File not found or could not be deleted: {}", fileName); + return false; + } + } + + /** + * Gets the Path for a given backup file name. + * + * @return the Path object for the given file name + */ + public Path getBackupFilePath(String fileName) { + Path filePath = Paths.get(BACKUP_DIR, fileName).normalize(); + if (!filePath.startsWith(BACKUP_DIR)) { + throw new SecurityException("Path traversal detected"); + } + return filePath; + } + + private void executeDatabaseScript(Path scriptPath) { + if (isH2Database()) { + String query = "RUNSCRIPT from ?;"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(query)) { + stmt.setString(1, scriptPath.toString()); + stmt.execute(); + } catch (SQLException e) { + log.error("Error during database import: {}", e.getMessage(), e); + } catch (ScriptException e) { + log.error("Error: File {} not found", scriptPath.toString(), e); + } + } + + log.info("Database import completed: {}", scriptPath); + } + + /** + * Checks for invalid characters or sequences + * + * @return true if it contains no invalid characters, false if it does + */ + private boolean isValidFileName(String fileName) { + return fileName != null + && !fileName.contains("..") + && !fileName.contains("/") + && !fileName.contains("\\") + && !fileName.contains(":") + && !fileName.contains("*") + && !fileName.contains("?") + && !fileName.contains("\"") + && !fileName.contains("<") + && !fileName.contains(">") + && !fileName.contains("|"); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/database/ScheduledTasks.java b/src/main/java/stirling/software/SPDF/config/security/database/ScheduledTasks.java index ad96573fc43..1bad2104bb3 100644 --- a/src/main/java/stirling/software/SPDF/config/security/database/ScheduledTasks.java +++ b/src/main/java/stirling/software/SPDF/config/security/database/ScheduledTasks.java @@ -1,21 +1,24 @@ package stirling.software.SPDF.config.security.database; -import java.io.IOException; +import java.sql.SQLException; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import stirling.software.SPDF.config.interfaces.DatabaseInterface; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; + @Component public class ScheduledTasks { - private final DatabaseBackupHelper databaseBackupService; + private final DatabaseInterface databaseService; - public ScheduledTasks(DatabaseBackupHelper databaseBackupService) { - this.databaseBackupService = databaseBackupService; + public ScheduledTasks(DatabaseInterface databaseService) { + this.databaseService = databaseService; } @Scheduled(cron = "0 0 0 * * ?") - public void performBackup() throws IOException { - databaseBackupService.exportDatabase(); + public void performBackup() throws SQLException, UnsupportedProviderException { + databaseService.exportDatabase(); } } diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 9f3f6e35984..ef4ac32477e 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.config.security.oauth2; import java.io.IOException; +import java.sql.SQLException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -18,6 +19,7 @@ import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.AuthenticationType; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; import stirling.software.SPDF.utils.RequestUriUtils; public class CustomOAuth2AuthenticationSuccessHandler @@ -97,10 +99,8 @@ public void onAuthenticationSuccess( userService.processSSOPostLogin(username, oAuth.getAutoCreateUser()); } response.sendRedirect(contextPath + "/"); - return; - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { response.sendRedirect(contextPath + "/logout?invalidUsername=true"); - return; } } } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index faa5e67ee74..c6c64a82484 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.config.security.saml2; import java.io.IOException; +import java.sql.SQLException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -18,6 +19,7 @@ import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security.SAML2; import stirling.software.SPDF.model.AuthenticationType; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; import stirling.software.SPDF.utils.RequestUriUtils; @AllArgsConstructor @@ -109,7 +111,7 @@ public void onAuthenticationSuccess( log.debug("Successfully processed authentication for user: {}", username); response.sendRedirect(contextPath + "/"); return; - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( "Invalid username detected for user: {}, redirecting to logout", username); diff --git a/src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java b/src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java index 35e2bb60323..8a5231c7153 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java @@ -24,7 +24,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.config.security.database.DatabaseBackupHelper; +import stirling.software.SPDF.config.security.database.DatabaseService; @Slf4j @Controller @@ -33,10 +33,10 @@ @Tag(name = "Database", description = "Database APIs for backup, import, and management") public class DatabaseController { - private final DatabaseBackupHelper databaseBackupHelper; + private final DatabaseService databaseService; - public DatabaseController(DatabaseBackupHelper databaseBackupHelper) { - this.databaseBackupHelper = databaseBackupHelper; + public DatabaseController(DatabaseService databaseService) { + this.databaseService = databaseService; } @Operation( @@ -57,7 +57,7 @@ public String importDatabase( Path tempTemplatePath = Files.createTempFile("backup_", ".sql"); try (InputStream in = file.getInputStream()) { Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING); - boolean importSuccess = databaseBackupHelper.importDatabaseFromUI(tempTemplatePath); + boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath); if (importSuccess) { redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed"); } else { @@ -77,21 +77,20 @@ public String importDatabase( @GetMapping("/import-database-file/{fileName}") public String importDatabaseFromBackupUI( @Parameter(description = "Name of the file to import", required = true) @PathVariable - String fileName) - throws IOException { + String fileName) { if (fileName == null || fileName.isEmpty()) { return "redirect:/database?error=fileNullOrEmpty"; } // Check if the file exists in the backup list boolean fileExists = - databaseBackupHelper.getBackupList().stream() + databaseService.getBackupList().stream() .anyMatch(backup -> backup.getFileName().equals(fileName)); if (!fileExists) { log.error("File {} not found in backup list", fileName); return "redirect:/database?error=fileNotFound"; } log.info("Received file: {}", fileName); - if (databaseBackupHelper.importDatabaseFromUI(fileName)) { + if (databaseService.importDatabaseFromUI(fileName)) { log.info("File {} imported to database", fileName); return "redirect:/database?infoMessage=importIntoDatabaseSuccessed"; } @@ -110,7 +109,7 @@ public String deleteFile( throw new IllegalArgumentException("File must not be null or empty"); } try { - if (databaseBackupHelper.deleteBackupFile(fileName)) { + if (databaseService.deleteBackupFile(fileName)) { log.info("Deleted file: {}", fileName); } else { log.error("Failed to delete file: {}", fileName); @@ -135,7 +134,7 @@ public ResponseEntity downloadFile( throw new IllegalArgumentException("File must not be null or empty"); } try { - Path filePath = databaseBackupHelper.getBackupFilePath(fileName); + Path filePath = databaseService.getBackupFilePath(fileName); InputStreamResource resource = new InputStreamResource(Files.newInputStream(filePath)); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName) @@ -157,14 +156,9 @@ public ResponseEntity downloadFile( + " database management page.") @GetMapping("/createDatabaseBackup") public String createDatabaseBackup() { - try { - log.info("Starting database backup creation..."); - databaseBackupHelper.exportDatabase(); - log.info("Database backup successfully created."); - } catch (IOException e) { - log.error("Error creating database backup: {}", e.getMessage(), e); - return "redirect:/database?error=" + e.getMessage(); - } + log.info("Starting database backup creation..."); + databaseService.exportDatabase(); + log.info("Database backup successfully created."); return "redirect:/database?infoMessage=backupCreated"; } } diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index 9a22025169b..f2534aadafb 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.security.Principal; +import java.sql.SQLException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,6 +34,7 @@ import stirling.software.SPDF.model.Role; import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.api.user.UsernameAndPass; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; @Controller @Tag(name = "User", description = "User APIs") @@ -52,7 +54,7 @@ public UserController(UserService userService, SessionPersistentRegistry session @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") public String register(@ModelAttribute UsernameAndPass requestModel, Model model) - throws IOException { + throws SQLException, UnsupportedProviderException { if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) { model.addAttribute("error", "Username already exists"); return "register"; @@ -74,7 +76,7 @@ public RedirectView changeUsername( HttpServletRequest request, HttpServletResponse response, RedirectAttributes redirectAttributes) - throws IOException { + throws IOException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(newUsername)) { return new RedirectView("/account?messageType=invalidUsername", true); } @@ -117,7 +119,7 @@ public RedirectView changePasswordOnLogin( HttpServletRequest request, HttpServletResponse response, RedirectAttributes redirectAttributes) - throws IOException { + throws SQLException, UnsupportedProviderException { if (principal == null) { return new RedirectView("/change-creds?messageType=notAuthenticated", true); } @@ -145,7 +147,7 @@ public RedirectView changePassword( HttpServletRequest request, HttpServletResponse response, RedirectAttributes redirectAttributes) - throws IOException { + throws SQLException, UnsupportedProviderException { if (principal == null) { return new RedirectView("/account?messageType=notAuthenticated", true); } @@ -166,7 +168,7 @@ public RedirectView changePassword( @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/updateUserSettings") public String updateUserSettings(HttpServletRequest request, Principal principal) - throws IOException { + throws SQLException, UnsupportedProviderException { Map paramMap = request.getParameterMap(); Map updates = new HashMap<>(); for (Map.Entry entry : paramMap.entrySet()) { @@ -188,7 +190,7 @@ public RedirectView saveUser( @RequestParam(name = "authType") String authType, @RequestParam(name = "forceChange", required = false, defaultValue = "false") boolean forceChange) - throws IllegalArgumentException, IOException { + throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(username)) { return new RedirectView("/addUsers?messageType=invalidUsername", true); } @@ -232,7 +234,7 @@ public RedirectView changeRole( @RequestParam(name = "username") String username, @RequestParam(name = "role") String role, Authentication authentication) - throws IOException { + throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (!userOpt.isPresent()) { return new RedirectView("/addUsers?messageType=userNotFound", true); @@ -270,7 +272,7 @@ public RedirectView changeUserEnabled( @PathVariable("username") String username, @RequestParam("enabled") boolean enabled, Authentication authentication) - throws IOException { + throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (!userOpt.isPresent()) { return new RedirectView("/addUsers?messageType=userNotFound", true); diff --git a/src/main/java/stirling/software/SPDF/controller/api/pipeline/ApiDocService.java b/src/main/java/stirling/software/SPDF/controller/api/pipeline/ApiDocService.java index 4992c2f6268..12a33051ef8 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/pipeline/ApiDocService.java +++ b/src/main/java/stirling/software/SPDF/controller/api/pipeline/ApiDocService.java @@ -20,7 +20,7 @@ import jakarta.servlet.ServletContext; import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.SPdfApplication; +import stirling.software.SPDF.SPDFApplication; import stirling.software.SPDF.model.ApiEndpoint; import stirling.software.SPDF.model.Role; @@ -44,7 +44,7 @@ public ApiDocService( private String getApiDocsUrl() { String contextPath = servletContext.getContextPath(); - String port = SPdfApplication.getStaticPort(); + String port = SPDFApplication.getStaticPort(); return "http://localhost:" + port + contextPath + "/v1/api-docs"; } diff --git a/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java b/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java index c9b741a0ad8..58ffe43b496 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java +++ b/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java @@ -30,7 +30,7 @@ import jakarta.servlet.ServletContext; import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.SPdfApplication; +import stirling.software.SPDF.SPDFApplication; import stirling.software.SPDF.model.PipelineConfig; import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.Role; @@ -80,7 +80,7 @@ private String getApiKeyForUser() { private String getBaseUrl() { String contextPath = servletContext.getContextPath(); - String port = SPdfApplication.getStaticPort(); + String port = SPDFApplication.getStaticPort(); return "http://localhost:" + port + contextPath + "/"; } diff --git a/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index 13be9a39c62..317c6424b34 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -14,7 +14,11 @@ import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cms.*; +import org.bouncycastle.cms.CMSProcessable; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.SignerInformation; +import org.bouncycastle.cms.SignerInformationStore; import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder; import org.bouncycastle.util.Store; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java b/src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java index eafcbd2673b..8c9f9108fee 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java @@ -11,17 +11,17 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import stirling.software.SPDF.config.security.database.DatabaseBackupHelper; +import stirling.software.SPDF.config.security.database.DatabaseService; import stirling.software.SPDF.utils.FileInfo; @Controller @Tag(name = "Database Management", description = "Database management and security APIs") public class DatabaseWebController { - private final DatabaseBackupHelper databaseBackupHelper; + private final DatabaseService databaseService; - public DatabaseWebController(DatabaseBackupHelper databaseBackupHelper) { - this.databaseBackupHelper = databaseBackupHelper; + public DatabaseWebController(DatabaseService databaseService) { + this.databaseService = databaseService; } @PreAuthorize("hasRole('ROLE_ADMIN')") @@ -34,9 +34,9 @@ public String database(HttpServletRequest request, Model model, Authentication a } else if (confirmed != null) { model.addAttribute("infoMessage", confirmed); } - List backupList = databaseBackupHelper.getBackupList(); + List backupList = databaseService.getBackupList(); model.addAttribute("backupFiles", backupList); - model.addAttribute("databaseVersion", databaseBackupHelper.getH2Version()); + model.addAttribute("databaseVersion", databaseService.getH2Version()); return "database"; } } diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index 9ff700c999f..1df2208f8cb 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -112,23 +112,6 @@ public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); } - public boolean isUserPass() { - return (loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString()) - || loginMethod.equalsIgnoreCase(LoginMethods.ALL.toString())); - } - - public boolean isOauth2Activ() { - return (oauth2 != null - && oauth2.getEnabled() - && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); - } - - public boolean isSaml2Activ() { - return (saml2 != null - && saml2.getEnabled() - && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); - } - public enum LoginMethods { ALL("all"), NORMAL("normal"), @@ -147,6 +130,23 @@ public String toString() { } } + public boolean isUserPass() { + return (loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString()) + || loginMethod.equalsIgnoreCase(LoginMethods.ALL.toString())); + } + + public boolean isOauth2Activ() { + return (oauth2 != null + && oauth2.getEnabled() + && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); + } + + public boolean isSaml2Activ() { + return (saml2 != null + && saml2.getEnabled() + && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); + } + @Data public static class InitialLogin { private String username; @@ -282,6 +282,42 @@ public static class System { private String tessdataDir; private Boolean enableAlphaFunctionality; private String enableAnalytics; + private Datasource datasource; + } + + @Data + public static class Datasource { + private boolean enableCustomDatabase; + private String customDatabaseUrl; + private String type; + private String hostName; + private Integer port; + private String name; + private String username; + @ToString.Exclude private String password; + } + + public enum Driver { + H2("h2"), + POSTGRESQL("postgresql"), + ORACLE("oracle"), + MYSQL("mysql"); + + private final String driverName; + + Driver(String driverName) { + this.driverName = driverName; + } + + @Override + public String toString() { + return """ + Driver { + driverName='%s' + } + """ + .formatted(driverName); + } } @Data diff --git a/src/main/java/stirling/software/SPDF/model/SessionEntity.java b/src/main/java/stirling/software/SPDF/model/SessionEntity.java index 3b4989d5ac6..fcdb8777b64 100644 --- a/src/main/java/stirling/software/SPDF/model/SessionEntity.java +++ b/src/main/java/stirling/software/SPDF/model/SessionEntity.java @@ -5,7 +5,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import lombok.Data; @@ -15,7 +14,7 @@ public class SessionEntity implements Serializable { @Id private String sessionId; - @Lob private String principalName; + private String principalName; private Date lastRequest; diff --git a/src/main/java/stirling/software/SPDF/model/User.java b/src/main/java/stirling/software/SPDF/model/User.java index 56bcdf332ac..2333472438c 100644 --- a/src/main/java/stirling/software/SPDF/model/User.java +++ b/src/main/java/stirling/software/SPDF/model/User.java @@ -47,7 +47,7 @@ public class User implements Serializable { @ElementCollection @MapKeyColumn(name = "setting_key") @Lob - @Column(name = "setting_value", columnDefinition = "CLOB") + @Column(name = "setting_value", columnDefinition = "text") @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) private Map settings = new HashMap<>(); // Key-value pairs of settings. diff --git a/src/main/java/stirling/software/SPDF/model/exception/BackupNotFoundException.java b/src/main/java/stirling/software/SPDF/model/exception/BackupNotFoundException.java new file mode 100644 index 00000000000..7e0649ce9c3 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/model/exception/BackupNotFoundException.java @@ -0,0 +1,7 @@ +package stirling.software.SPDF.model.exception; + +public class BackupNotFoundException extends RuntimeException { + public BackupNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f0d2e6a739c..cccfd3b063a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -28,7 +28,7 @@ spring.thymeleaf.encoding=UTF-8 spring.web.resources.mime-mappings.webmanifest=application/manifest+json spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000} -spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index a110744a02f..248aa93cc03 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -85,6 +85,15 @@ system: customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. enableAnalytics: undefined # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true + datasource: + enableCustomDatabase: false # set this property to 'true' if you would like to use your own custom database configuration + customDatabaseUrl: jdbc:postgresql://localhost:5432/postgres # set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used + username: postgres # set the database username + password: postgres # set the database password + type: postgresql # the type of the database to set (e.g. 'h2', 'postgresql') + hostName: localhost # the host name to use for the database url. Set to 'localhost' when running the app locally. Set to match the name of the container name of your database container when running the app on a server (Docker configuration) + port: 5432 # set the port number of the database. Ensure this matches the port the database is listening to + name: postgres # set the name of your database. Should match the name of the database you create ui: appName: '' # application's visible name diff --git a/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java b/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java new file mode 100644 index 00000000000..e155527b763 --- /dev/null +++ b/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java @@ -0,0 +1,56 @@ +package stirling.software.SPDF; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; + +import stirling.software.SPDF.model.ApplicationProperties; +import static java.nio.file.Files.createDirectories; +import static java.nio.file.Files.createFile; +import static java.nio.file.Files.delete; +import static java.nio.file.Files.exists; + +@ExtendWith(MockitoExtension.class) +public class SPDFApplicationTest { + + @Mock + private Environment env; + + @Mock + private ApplicationProperties applicationProperties; + + @InjectMocks + private SPDFApplication SPDFApplication; + + @BeforeEach + public void setUp() { + SPDFApplication.setServerPortStatic("8080"); + } + + @Test + public void testSetServerPortStatic() { + SPDFApplication.setServerPortStatic("9090"); + assertEquals("9090", SPDFApplication.getStaticPort()); + } + + @Test + public void testGetStaticPort() { + assertEquals("8080", SPDFApplication.getStaticPort()); + } + + @Test + public void testGetNonStaticPort() { + assertEquals("8080", SPDFApplication.getNonStaticPort()); + } +} diff --git a/src/test/java/stirling/software/SPDF/SPdfApplicationTest.java b/src/test/java/stirling/software/SPDF/SPdfApplicationTest.java deleted file mode 100644 index 37f27918b1a..00000000000 --- a/src/test/java/stirling/software/SPDF/SPdfApplicationTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package stirling.software.SPDF; - -import static org.junit.jupiter.api.Assertions.*; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.env.Environment; - -import stirling.software.SPDF.UI.WebBrowser; -import stirling.software.SPDF.model.ApplicationProperties; - -@ExtendWith(MockitoExtension.class) -public class SPdfApplicationTest { - - @Mock - private Environment env; - - @Mock - private ApplicationProperties applicationProperties; - - @InjectMocks - private SPdfApplication sPdfApplication; - - @BeforeEach - public void setUp() { - sPdfApplication.setServerPortStatic("8080"); - } - - @Test - public void testSetServerPortStatic() { - sPdfApplication.setServerPortStatic("9090"); - assertEquals("9090", SPdfApplication.getStaticPort()); - } - - @Test - public void testMainApplicationStartup() throws IOException, InterruptedException { - // Setup mock environment for the main method - Path configPath = Path.of("test/configs"); - Path settingsPath = Paths.get("test/configs/settings.yml"); - Path customSettingsPath = Paths.get("test/configs/custom_settings.yml"); - Path staticPath = Path.of("test/customFiles/static/"); - Path templatesPath = Path.of("test/customFiles/templates/"); - - // Ensure the files do not exist for the test - if (Files.exists(settingsPath)) { - Files.delete(settingsPath); - } - if (Files.exists(customSettingsPath)) { - Files.delete(customSettingsPath); - } - if (Files.exists(staticPath)) { - Files.delete(staticPath); - } - if (Files.exists(templatesPath)) { - Files.delete(templatesPath); - } - - // Ensure the directories are created for testing - Files.createDirectories(configPath); - Files.createDirectories(staticPath); - Files.createDirectories(templatesPath); - - Files.createFile(settingsPath); - Files.createFile(customSettingsPath); - - // Run the main method - SPdfApplication.main(new String[]{}); - - // Verify that the directories were created - assertTrue(Files.exists(settingsPath)); - assertTrue(Files.exists(customSettingsPath)); - assertTrue(Files.exists(staticPath)); - assertTrue(Files.exists(templatesPath)); - } - - @Test - public void testGetStaticPort() { - assertEquals("8080", SPdfApplication.getStaticPort()); - } - - @Test - public void testGetNonStaticPort() { - assertEquals("8080", sPdfApplication.getNonStaticPort()); - } -} diff --git a/src/test/java/stirling/software/SPDF/config/security/database/DatabaseConfigTest.java b/src/test/java/stirling/software/SPDF/config/security/database/DatabaseConfigTest.java new file mode 100644 index 00000000000..2c61ce1a7e0 --- /dev/null +++ b/src/test/java/stirling/software/SPDF/config/security/database/DatabaseConfigTest.java @@ -0,0 +1,106 @@ +package stirling.software.SPDF.config.security.database; + +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.SPDF.model.ApplicationProperties; +import stirling.software.SPDF.model.provider.UnsupportedProviderException; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DatabaseConfigTest { + + @Mock + private ApplicationProperties applicationProperties; + + private DatabaseConfig databaseConfig; + + @BeforeEach + void setUp() { + databaseConfig = new DatabaseConfig(applicationProperties, true); + } + + @Test + void testDataSource_whenRunningEEIsFalse() throws UnsupportedProviderException { + databaseConfig = new DatabaseConfig(applicationProperties, false); + + var result = databaseConfig.dataSource(); + + assertInstanceOf(DataSource.class, result); + } + + @Test + void testDefaultConfigurationForDataSource() throws UnsupportedProviderException { + var system = mock(ApplicationProperties.System.class); + var datasource = mock(ApplicationProperties.Datasource.class); + + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getDatasource()).thenReturn(datasource); + when(datasource.isEnableCustomDatabase()).thenReturn(false); + + var result = databaseConfig.dataSource(); + + assertInstanceOf(DataSource.class, result); + } + + @Test + void testCustomUrlForDataSource() throws UnsupportedProviderException { + var system = mock(ApplicationProperties.System.class); + var datasource = mock(ApplicationProperties.Datasource.class); + + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getDatasource()).thenReturn(datasource); + when(datasource.isEnableCustomDatabase()).thenReturn(true); + when(datasource.getCustomDatabaseUrl()).thenReturn("jdbc:postgresql://mockUrl"); + when(datasource.getUsername()).thenReturn("test"); + when(datasource.getPassword()).thenReturn("pass"); + + var result = databaseConfig.dataSource(); + + assertInstanceOf(DataSource.class, result); + } + + @Test + void testCustomConfigurationForDataSource() throws UnsupportedProviderException { + var system = mock(ApplicationProperties.System.class); + var datasource = mock(ApplicationProperties.Datasource.class); + + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getDatasource()).thenReturn(datasource); + when(datasource.isEnableCustomDatabase()).thenReturn(true); + when(datasource.getCustomDatabaseUrl()).thenReturn(""); + when(datasource.getType()).thenReturn("postgresql"); + when(datasource.getHostName()).thenReturn("test"); + when(datasource.getPort()).thenReturn(1234); + when(datasource.getName()).thenReturn("test_db"); + when(datasource.getUsername()).thenReturn("test"); + when(datasource.getPassword()).thenReturn("pass"); + + var result = databaseConfig.dataSource(); + + assertInstanceOf(DataSource.class, result); + } + + @ParameterizedTest(name = "Exception thrown when the DB type [{arguments}] is not supported") + @ValueSource(strings = {"oracle", "mysql", "mongoDb"}) + void exceptionThrown_whenDBTypeIsUnsupported(String datasourceType) { + var system = mock(ApplicationProperties.System.class); + var datasource = mock(ApplicationProperties.Datasource.class); + + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getDatasource()).thenReturn(datasource); + when(datasource.isEnableCustomDatabase()).thenReturn(true); + when(datasource.getCustomDatabaseUrl()).thenReturn(""); + when(datasource.getType()).thenReturn(datasourceType); + + assertThrows(UnsupportedProviderException.class, () -> databaseConfig.dataSource()); + } +} \ No newline at end of file