2270: cleaning up

This commit is contained in:
DarioGii 2024-12-20 20:04:56 +00:00
parent 6b07439798
commit 3584156293
32 changed files with 753 additions and 418 deletions

View File

@ -297,10 +297,14 @@ dependencies {
implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion"
implementation "org.springframework.session:spring-session-core:$springBootVersion"
implementation "org.springframework:spring-jdbc"
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"
implementation "com.unboundid.product.scim2:scim2-sdk-client:2.3.5"
constraints {
implementation "org.opensaml:opensaml-core:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
@ -388,6 +392,7 @@ tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
dependsOn "spotlessApply"
}
compileJava {
options.compilerArgs << "-parameters"
}

View File

@ -6,6 +6,8 @@ services:
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
@ -31,3 +33,14 @@ services:
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
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"

View File

@ -6,6 +6,8 @@ services:
resources:
limits:
memory: 4G
depends_on:
- db
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
@ -39,3 +41,14 @@ services:
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
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"

View File

@ -6,6 +6,8 @@ services:
resources:
limits:
memory: 4G
depends_on:
- db
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
@ -30,4 +32,24 @@ services:
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SYSTEM_DATASOURCE_TYPE: "postgresql"
SYSTEM_DATASOURCE_HOSTNAME: "db"
SYSTEM_DATASOURCE_PORT: "5432"
SYSTEM_DATASOURCE_NAME: "stirling_pdf"
SYSTEM_DATASOURCE_USEDEFAULT: "false"
SYSTEM_DATASOURCE_USERNAME: "admin"
SYSTEM_DATASOURCE_PASSWORD: "stirling"
# SPRING_JPA_HIBERNATE_DDL_AUTO: "update"
# SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: "org.hibernate.dialect.PostgreSQLDialect"
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"

View File

@ -6,6 +6,8 @@ services:
resources:
limits:
memory: 1G
depends_on:
- db
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
@ -28,3 +30,15 @@ services:
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
restart: on-failure:5
db:
image: 'postgres:17.2-alpine'
restart: on-failure:5
shm_size: 32mb
container_name: db
ports:
- "5432:5432"
environment:
POSTGRES_DB: "stirling_pdf"
POSTGRES_USER: "admin"
POSTGRES_PASSWORD: "stirling"

View File

@ -1,6 +1,5 @@
package stirling.software.SPDF;
import java.awt.*;
import java.io.IOException;
import java.net.ServerSocket;
import java.nio.file.Files;
@ -11,8 +10,6 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import javax.swing.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
@ -29,51 +26,25 @@ import stirling.software.SPDF.UI.WebBrowser;
import stirling.software.SPDF.config.ConfigInitializer;
import stirling.software.SPDF.model.ApplicationProperties;
@SpringBootApplication
@EnableScheduling
@Slf4j
public class SPdfApplication {
@EnableScheduling
@SpringBootApplication
public class SPDFApplication {
private static String serverPortStatic;
private static String baseUrlStatic;
@Autowired private Environment env;
@Autowired ApplicationProperties applicationProperties;
@Autowired private ApplicationProperties applicationProperties;
private static String baseUrlStatic;
private static String serverPortStatic;
@Autowired(required = false)
private WebBrowser webBrowser;
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@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;
}
}
// 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();
@ -84,7 +55,7 @@ public class SPdfApplication {
props.put("spring.main.web-application-type", "servlet");
}
app.setAdditionalProfiles("default");
app.setAdditionalProfiles(getActiveProfile(args));
app.addInitializers(new ConfigInitializer());
Map<String, String> propertyFiles = new HashMap<>();
@ -134,15 +105,6 @@ public class SPdfApplication {
printStartupLogs();
}
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort();
log.info("Navigate to {}", url);
}
@Autowired(required = false)
private WebBrowser webBrowser;
@PostConstruct
public void init() {
baseUrlStatic = this.baseUrl;
@ -173,6 +135,17 @@ public class SPdfApplication {
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) {
@ -180,6 +153,43 @@ public class SPdfApplication {
}
}
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;
}

View File

@ -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<FileInfo> getBackupList();
}

View File

@ -0,0 +1,13 @@
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;
List<FileInfo> getBackupList();
}

View File

@ -20,7 +20,7 @@ import jakarta.servlet.http.HttpServletRequest;
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 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
// Construct URLs required for SAML configuration
String serverUrl =
SPdfApplication.getStaticBaseUrl() + ":" + SPdfApplication.getStaticPort();
SPDFApplication.getStaticBaseUrl() + ":" + SPDFApplication.getStaticPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
@ -219,9 +219,9 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
// "https://accounts.google.com/Logout?continue=https://appengine.google.com/_ah/logout?continue="
// + response.encodeRedirectURL(redirect_url);
log.info("Google does not have a specific logout URL");
// log.info("Redirecting to Google logout URL: " + googleLogoutUrl);
// response.sendRedirect(googleLogoutUrl);
// break;
// log.info("Redirecting to Google logout URL: " + googleLogoutUrl);
// response.sendRedirect(googleLogoutUrl);
// break;
default:
String defaultRedirectUrl = request.getContextPath() + "/login?" + param;
log.info("Redirecting to default logout URL: " + defaultRedirectUrl);

View File

@ -1,6 +1,6 @@
package stirling.software.SPDF.config.security;
import java.io.IOException;
import java.sql.SQLException;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
@ -8,35 +8,37 @@ 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 {
@Autowired private UserService userService;
@Autowired private ApplicationProperties applicationProperties;
// todo: wip add Postgres here
@Autowired private DatabaseBackupInterface databaseBackupHelper;
@Autowired private DatabaseInterface 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 (!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 =
@ -45,36 +47,34 @@ public class InitialSecuritySetup {
&& !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());
}

View File

@ -1,6 +1,6 @@
package stirling.software.SPDF.config.security;
import java.io.IOException;
import java.sql.SQLException;
import java.util.*;
import java.util.stream.Collectors;
@ -21,7 +21,7 @@ import org.springframework.stereotype.Service;
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;
@ -30,6 +30,7 @@ import stirling.software.SPDF.model.AuthenticationType;
import stirling.software.SPDF.model.Authority;
import stirling.software.SPDF.model.Role;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.provider.UnsupportedProviderException;
import stirling.software.SPDF.repository.AuthorityRepository;
import stirling.software.SPDF.repository.UserRepository;
@ -47,7 +48,7 @@ public class UserService implements UserServiceInterface {
@Autowired private SessionPersistentRegistry sessionRegistry;
@Autowired DatabaseBackupInterface databaseBackupHelper;
@Autowired DatabaseInterface databaseService;
@Autowired ApplicationProperties applicationProperties;
@ -64,7 +65,7 @@ public class UserService implements UserServiceInterface {
// 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;
}
@ -151,12 +152,12 @@ public class UserService implements UserServiceInterface {
}
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());
}
@ -167,11 +168,11 @@ public class UserService implements UserServiceInterface {
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());
}
@ -181,11 +182,11 @@ public class UserService implements UserServiceInterface {
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());
}
@ -197,11 +198,11 @@ public class UserService implements UserServiceInterface {
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);
}
@ -235,7 +236,7 @@ public class UserService implements UserServiceInterface {
}
public void updateUserSettings(String username, Map<String, String> updates)
throws IOException {
throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = findByUsernameIgnoreCaseWithSettings(username);
if (userOpt.isPresent()) {
User user = userOpt.get();
@ -249,7 +250,7 @@ public class UserService implements UserServiceInterface {
user.setSettings(settingsMap);
userRepository.save(user);
databaseBackupHelper.exportDatabase();
databaseService.exportDatabase();
}
}
@ -270,38 +271,42 @@ public class UserService implements UserServiceInterface {
}
public void changeUsername(User user, String newUsername)
throws IllegalArgumentException, IOException {
throws IllegalArgumentException, 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) {
@ -391,7 +396,8 @@ public class UserService implements UserServiceInterface {
}
@Transactional
public void syncCustomApiUser(String customApiKey) throws IOException {
public void syncCustomApiUser(String customApiKey)
throws SQLException, UnsupportedProviderException {
if (customApiKey == null || customApiKey.trim().length() == 0) {
return;
}
@ -409,14 +415,14 @@ public class UserService implements UserServiceInterface {
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();
}
}
}

View File

@ -0,0 +1,123 @@
package stirling.software.SPDF.config.security.database;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
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;
@Getter
@Slf4j
@Configuration
public class DatabaseConfig {
public static final String DATASOURCE_URL_TEMPLATE = "jdbc:%s://%s:%4d/%s";
private final ApplicationProperties applicationProperties;
public DatabaseConfig(@Autowired ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
/**
* Creates the <code>DataSource</code> for the connection to the DB. If <code>useDefault</code>
* is set to <code>true</code>, it will use the default H2 DB. If it is set to <code>false
* </code>, it will use the user's custom configuration set in the settings.yml.
*
* @return a <code>DataSource</code> using the configuration settings in the settings.yml
* @throws UnsupportedProviderException if the type of database selected is not supported
*/
@Bean
public DataSource dataSource() throws UnsupportedProviderException {
ApplicationProperties.System system = applicationProperties.getSystem();
ApplicationProperties.Datasource datasource = system.getDatasource();
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
if (datasource.isUseDefault()) {
log.debug("Using default H2 database");
dataSourceBuilder.driverClassName("org.h2.Driver");
dataSourceBuilder.url(datasource.getDefaultUrl());
dataSourceBuilder.username("sa");
return dataSourceBuilder.build();
}
dataSourceBuilder.driverClassName(getDriverClassName(datasource.getType()));
dataSourceBuilder.url(
getDataSourceUrl(
datasource.getType(),
datasource.getHostName(),
datasource.getPort(),
datasource.getName()));
dataSourceBuilder.username(datasource.getUsername());
dataSourceBuilder.password(datasource.getPassword());
return dataSourceBuilder.build();
}
/**
* Generate the URL the <code>DataSource</code> 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 <code>DataSource</code> URL
*/
private String getDataSourceUrl(
String dataSourceType, String hostname, Integer port, String dataSourceName) {
return DATASOURCE_URL_TEMPLATE.formatted(dataSourceType, hostname, port, dataSourceName);
}
/**
* @return a <code>Connection</code> using the configured <code>DataSource</code>
* @throws SQLException if a database access error occurs
* @throws UnsupportedProviderException when an unsupported database is selected
*/
public Connection connection() throws SQLException, UnsupportedProviderException {
return dataSource().getConnection();
}
/**
* 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 "org.h2.Driver";
}
case POSTGRESQL -> {
log.debug("Postgres driver selected");
return "org.postgresql.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");
}
}
}

View File

@ -7,8 +7,6 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
@ -20,49 +18,41 @@ 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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.jdbc.datasource.init.ScriptException;
import org.springframework.jdbc.datasource.init.ScriptUtils;
import org.springframework.stereotype.Service;
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.provider.UnsupportedProviderException;
import stirling.software.SPDF.utils.FileInfo;
@Slf4j
@Configuration
public class DatabaseBackupHelper implements DatabaseBackupInterface {
@Service
public class DatabaseService implements DatabaseInterface {
@Value("${spring.datasource.url}")
private String url;
public static final String BACKUP_PREFIX = "backup_";
public static final String SQL_SUFFIX = ".sql";
private static final Path BACKUP_PATH = Paths.get("configs/db/backup/");
@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();
}
@Autowired private DatabaseConfig databaseConfig;
@Override
public List<FileInfo> getBackupList() {
// Check if the backup directory exists, and create it if it does not
ensureBackupDirectoryExists();
List<FileInfo> backupFiles = new ArrayList<>();
// Read the backup directory and filter for files with the prefix "backup_" and suffix
// ".sql"
try (DirectoryStream<Path> stream =
Files.newDirectoryStream(
backupPath,
BACKUP_PATH,
path ->
path.getFileName().toString().startsWith("backup_")
&& path.getFileName().toString().endsWith(".sql"))) {
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 =
@ -83,90 +73,105 @@ public class DatabaseBackupHelper implements DatabaseBackupInterface {
} 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));
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 {
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;
private void 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);
}
@Override
public boolean importDatabase() {
if (!this.hasBackup()) return false;
List<FileInfo> backupList = this.getBackupList();
backupList.sort(Comparator.comparing(FileInfo::getModificationDate).reversed());
return executeDatabaseScript(Paths.get(backupList.get(0).getFilePath()));
}
// fixMe: Needs to check the type of DB before executing script
@Override
public void exportDatabase() throws IOException {
// Check if the backup directory exists, and create it if it does not
ensureBackupDirectoryExists();
public void exportDatabase() throws SQLException, UnsupportedProviderException {
// Filter and delete old backups if there are more than 5
List<FileInfo> filteredBackupList =
this.getBackupList().stream()
.filter(backup -> !backup.getFileName().startsWith("backup_user_"))
.filter(backup -> !backup.getFileName().startsWith(BACKUP_PREFIX + "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());
deleteOldestBackup(filteredBackupList);
}
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 ?;";
this.getBackupFilePath(BACKUP_PREFIX + dateNow.format(myFormatObj) + SQL_SUFFIX);
try (Connection conn = databaseConfig.connection()) {
ScriptUtils.executeSqlScript(
conn, new EncodedResource(new PathResource(insertOutputFilePath)));
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) {
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error during database export: {}", e.getMessage(), e);
throw e;
} catch (ScriptException e) {
log.error("Error during database export: File {} not found", insertOutputFilePath);
throw e;
}
}
private static void deleteOldestBackup(List<FileInfo> 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.
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);
if (databaseConfig
.getApplicationProperties()
.getSystem()
.getDatasource()
.getType()
.equals(ApplicationProperties.Driver.H2.name())) {
try (Connection conn = databaseConfig.connection()) {
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 | UnsupportedProviderException e) {
log.error("Error retrieving H2 version: {}", e.getMessage(), e);
}
} catch (SQLException e) {
log.error("Error retrieving H2 version: {}", e.getMessage(), e);
}
return version;
}
@ -188,36 +193,22 @@ public class DatabaseBackupHelper implements DatabaseBackupInterface {
// 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)) {
Path filePath = Paths.get(BACKUP_PATH.toString(), fileName).normalize();
if (!filePath.startsWith(BACKUP_PATH)) {
throw new SecurityException("Path traversal detected");
}
return filePath;
}
private boolean executeDatabaseScript(Path scriptPath) {
String query = "RUNSCRIPT from ?;";
private void executeDatabaseScript(Path scriptPath) {
try (Connection conn = databaseConfig.connection()) {
ScriptUtils.executeSqlScript(conn, new EncodedResource(new PathResource(scriptPath)));
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) {
} catch (SQLException | UnsupportedProviderException 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());
}
} catch (ScriptException e) {
log.error("Error: File {} not found", scriptPath.toString(), e);
}
}

View File

@ -1,18 +1,20 @@
package stirling.software.SPDF.config.security.database;
import java.io.IOException;
import java.sql.SQLException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import stirling.software.SPDF.model.provider.UnsupportedProviderException;
@Component
public class ScheduledTasks {
@Autowired private DatabaseBackupHelper databaseBackupService;
@Autowired private DatabaseService databaseService;
@Scheduled(cron = "0 0 0 * * ?")
public void performBackup() throws IOException {
databaseBackupService.exportDatabase();
public void performBackup() throws SQLException, UnsupportedProviderException {
databaseService.exportDatabase();
}
}

View File

@ -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.config.security.UserService;
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 class CustomOAuth2AuthenticationSuccessHandler
userService.processSSOPostLogin(username, oAuth.getAutoCreateUser());
}
response.sendRedirect(contextPath + "/");
return;
} catch (IllegalArgumentException e) {
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
return;
}
}
}

View File

@ -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.config.security.UserService;
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
@ -108,13 +110,14 @@ public class CustomSaml2AuthenticationSuccessHandler
userService.processSSOPostLogin(username, saml2.getAutoCreateUser());
log.debug("Successfully processed authentication for user: {}", username);
response.sendRedirect(contextPath + "/");
return;
} catch (IllegalArgumentException e) {
log.debug(
"Invalid username detected for user: {}, redirecting to logout",
username);
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
return;
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error, redirecting to logout", e);
response.sendRedirect(contextPath + "/logout?error=true");
}
}
} else {

View File

@ -29,7 +29,7 @@ import io.swagger.v3.oas.annotations.Parameter;
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
@ -38,19 +38,16 @@ import stirling.software.SPDF.config.security.database.DatabaseBackupHelper;
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
public class DatabaseController {
@Autowired DatabaseBackupHelper databaseBackupHelper;
@Autowired DatabaseService databaseService;
@Operation(
summary = "Import a database backup file",
description = "Uploads and imports a database backup SQL file.")
@Hidden
@PostMapping(consumes = "multipart/form-data", value = "import-database")
@Operation(
summary = "Import database backup",
description = "This endpoint imports a database backup from a SQL file.")
public String importDatabase(
@Parameter(description = "SQL file to import", required = true)
@RequestParam("fileInput")
MultipartFile file,
RedirectAttributes redirectAttributes)
throws IOException {
@RequestParam("fileInput") MultipartFile file, RedirectAttributes redirectAttributes)
throws IllegalArgumentException, IOException {
if (file == null || file.isEmpty()) {
redirectAttributes.addAttribute("error", "fileNullOrEmpty");
return "redirect:/database";
@ -60,7 +57,8 @@ public class DatabaseController {
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.toString());
if (importSuccess) {
redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed");
} else {
@ -80,8 +78,7 @@ public class DatabaseController {
@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";
@ -89,15 +86,14 @@ public class DatabaseController {
// 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";
}
@ -105,19 +101,17 @@ public class DatabaseController {
}
@Hidden
@GetMapping("/delete/{fileName}")
@Operation(
summary = "Delete a database backup file",
description = "Deletes a specified database backup file from the server.")
@GetMapping("/delete/{fileName}")
public String deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
String fileName) {
description =
"This endpoint deletes a database backup file with the specified file name.")
public String deleteFile(@PathVariable String fileName) {
if (fileName == null || fileName.isEmpty()) {
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);
@ -131,18 +125,17 @@ public class DatabaseController {
}
@Hidden
@GetMapping("/download/{fileName}")
@Operation(
summary = "Download a database backup file",
description = "Downloads the specified database backup file from the server.")
@GetMapping("/download/{fileName}")
public ResponseEntity<?> downloadFile(
@Parameter(description = "Name of the file to download", required = true) @PathVariable
String fileName) {
description =
"This endpoint downloads a database backup file with the specified file name.")
public ResponseEntity<?> downloadFile(@PathVariable String fileName) {
if (fileName == null || fileName.isEmpty()) {
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)
@ -156,22 +149,4 @@ public class DatabaseController {
.build();
}
}
@Operation(
summary = "Create a database backup",
description =
"This endpoint triggers the creation of a database backup and redirects to the"
+ " 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();
}
return "redirect:/database?infoMessage=backupCreated";
}
}

View File

@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.security.Principal;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -38,6 +39,7 @@ import stirling.software.SPDF.model.AuthenticationType;
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")
@ -51,17 +53,17 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
throws IOException {
public String register(@ModelAttribute UsernameAndPass requestModel, Model model) {
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
model.addAttribute("error", "Username already exists");
return "register";
}
try {
userService.saveUser(requestModel.getUsername(), requestModel.getPassword());
} catch (IllegalArgumentException e) {
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
return "redirect:/login?messageType=invalidUsername";
}
return "redirect:/login?registered=true";
}
@ -73,8 +75,7 @@ public class UserController {
@RequestParam(name = "newUsername") String newUsername,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
throws IOException {
RedirectAttributes redirectAttributes) {
if (!userService.isUsernameValid(newUsername)) {
return new RedirectView("/account?messageType=invalidUsername", true);
@ -108,7 +109,7 @@ public class UserController {
if (newUsername != null && newUsername.length() > 0) {
try {
userService.changeUsername(user, newUsername);
} catch (IllegalArgumentException e) {
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
return new RedirectView("/account?messageType=invalidUsername", true);
}
}
@ -128,7 +129,7 @@ public class UserController {
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
throws IOException {
throws SQLException, UnsupportedProviderException {
if (principal == null) {
return new RedirectView("/change-creds?messageType=notAuthenticated", true);
}
@ -162,7 +163,7 @@ public class UserController {
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
throws IOException {
throws SQLException, UnsupportedProviderException {
if (principal == null) {
return new RedirectView("/account?messageType=notAuthenticated", true);
}
@ -190,7 +191,7 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/updateUserSettings")
public String updateUserSettings(HttpServletRequest request, Principal principal)
throws IOException {
throws SQLException, UnsupportedProviderException {
Map<String, String[]> paramMap = request.getParameterMap();
Map<String, String> updates = new HashMap<>();
@ -215,7 +216,7 @@ public class UserController {
@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);
@ -263,7 +264,7 @@ public class UserController {
@RequestParam(name = "username") String username,
@RequestParam(name = "role") String role,
Authentication authentication)
throws IOException {
throws IOException, SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
@ -280,6 +281,7 @@ public class UserController {
if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=downgradeCurrentUser", true);
}
try {
// Validate the role
Role roleEnum = Role.fromString(role);
@ -305,7 +307,7 @@ public class UserController {
@PathVariable("username") String username,
@RequestParam("enabled") boolean enabled,
Authentication authentication)
throws IOException {
throws IOException, SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);

View File

@ -20,12 +20,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
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;
@Service
@Slf4j
@Service
public class ApiDocService {
private final Map<String, ApiEndpoint> apiDocumentation = new HashMap<>();
@ -34,7 +34,7 @@ public class ApiDocService {
private String getApiDocsUrl() {
String contextPath = servletContext.getContextPath();
String port = SPdfApplication.getStaticPort();
String port = SPDFApplication.getStaticPort();
return "http://localhost:" + port + contextPath + "/v1/api-docs";
}

View File

@ -39,7 +39,7 @@ import io.github.pixee.security.ZipSecurity;
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;
@ -62,7 +62,7 @@ public class PipelineProcessor {
private String getBaseUrl() {
String contextPath = servletContext.getContextPath();
String port = SPdfApplication.getStaticPort();
String port = SPDFApplication.getStaticPort();
return "http://localhost:" + port + contextPath + "/";
}

View File

@ -167,8 +167,8 @@ public class AccountWebController {
case "invalid_destination":
erroroauth = "login.invalid_destination";
break;
// Valid InResponseTo was not available from the validation context, unable to
// evaluate
// Valid InResponseTo was not available from the validation context, unable to
// evaluate
case "invalid_in_response_to":
erroroauth = "login.invalid_in_response_to";
break;

View File

@ -12,14 +12,14 @@ import org.springframework.web.bind.annotation.GetMapping;
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 {
@Autowired private DatabaseBackupHelper databaseBackupHelper;
@Autowired private DatabaseService databaseService;
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/database")
@ -33,10 +33,10 @@ public class DatabaseWebController {
model.addAttribute("infoMessage", confirmed);
}
List<FileInfo> backupList = databaseBackupHelper.getBackupList();
List<FileInfo> backupList = databaseService.getBackupList();
model.addAttribute("backupFiles", backupList);
model.addAttribute("databaseVersion", databaseBackupHelper.getH2Version());
model.addAttribute("databaseVersion", databaseService.getH2Version());
return "database";
}

View File

@ -6,7 +6,6 @@ import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.sql.Driver;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@ -253,23 +252,37 @@ public class ApplicationProperties {
@Data
public static class Datasource {
private String url;
private Driver driver;
private String type;
private String hostName;
private Integer port;
private String name;
private String username;
private String password;
@ToString.Exclude private String password;
private boolean useDefault;
private final String defaultUrl;
}
public enum Driver {
H2("h2"),
POSTGRESQL("postgresql"),
ORACLE("oracle"),
MY_SQL("mysql");
MYSQL("mysql");
private final String driverName;
Driver(String driverName) {
this.driverName = driverName;
}
@Override
public String toString() {
return """
Driver {
driverName='%s'
}
"""
.formatted(driverName);
}
}
@Data

View File

@ -5,7 +5,6 @@ import java.util.Date;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.Data;
@ -15,7 +14,7 @@ import lombok.Data;
public class SessionEntity implements Serializable {
@Id private String sessionId;
@Lob private String principalName;
private String principalName;
private Date lastRequest;

View File

@ -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<String, String> settings = new HashMap<>(); // Key-value pairs of settings.

View File

@ -41,13 +41,12 @@ spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
#spring.thymeleaf.prefix=file:/customFiles/templates/,classpath:/templates/
#spring.thymeleaf.cache=false
spring.datasource.url=jdbc:postgresql://localhost:5432/stirling-pdf-DB
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.username=postgres
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.generate-ddl=true
spring.h2.console.enabled=false
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
server.servlet.session.timeout=30m
# Change the default URL path for OpenAPI JSON
springdoc.api-docs.path=/v1/api-docs
@ -55,6 +54,5 @@ springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
posthog.host=https://eu.i.posthog.com

View File

@ -86,10 +86,14 @@ system:
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:
url: jdbc:postgresql://localhost:5432/stirling-pdf-DB
driver: postgresql
username: postgres
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
username: postgres # set the database username
password: postgres # set the database password
useDefault: 'true' # set this property to 'true' if you would like to use the default database configuration
defaultUrl: jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE # the default database url for the application
ui:
appName: '' # application's visible name

View File

@ -0,0 +1,47 @@
package stirling.software.SPDF;
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 org.junit.jupiter.api.Assertions.assertEquals;
@ExtendWith(MockitoExtension.class)
public class SPDFApplicationTest {
@Mock
private Environment env;
@Mock
private ApplicationProperties applicationProperties;
@InjectMocks
private SPDFApplication sPdfApplication;
@BeforeEach
public void setUp() {
sPdfApplication = new SPDFApplication();
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());
}
}

View File

@ -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.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 = new SPdfApplication();
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());
}
}

View File

@ -0,0 +1,56 @@
package stirling.software.SPDF.config.security;
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 stirling.software.SPDF.config.security.database.DatabaseService;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.provider.UnsupportedProviderException;
import java.sql.SQLException;
import java.util.Optional;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InitialSecuritySetupTest {
@Mock
private UserService userService;
@Mock
private ApplicationProperties applicationProperties;
@Mock
private DatabaseService databaseService;
@InjectMocks
private InitialSecuritySetup initialSecuritySetup;
@Test
void testInit() throws SQLException, UnsupportedProviderException {
String username = "admin";
String password = "stirling";
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
ApplicationProperties.Security.InitialLogin initialLogin = mock(ApplicationProperties.Security.InitialLogin.class);
Optional<User> user = Optional.of(mock(User.class));
when(userService.hasUsers()).thenReturn(false);
when(applicationProperties.getSecurity()).thenReturn(security);
when(security.getInitialLogin()).thenReturn(initialLogin);
when(initialLogin.getUsername()).thenReturn(username);
when(initialLogin.getPassword()).thenReturn(password);
when(userService.findByUsernameIgnoreCase(username)).thenReturn(user);
when(userService.usernameExistsIgnoreCase(anyString())).thenReturn(false);
initialSecuritySetup.init();
verify(userService).saveUser(anyString(), anyString(), anyString());
verify(userService).migrateOauth2ToSSO();
verify(userService).addApiKeyToUser(anyString());
}
}

View File

@ -0,0 +1,77 @@
package stirling.software.SPDF.config.security.database;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.provider.UnsupportedProviderException;
import javax.sql.DataSource;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DatabaseConfigTest {
@Mock
private ApplicationProperties applicationProperties;
@InjectMocks
private DatabaseConfig databaseConfig;
@Test
void testDefaultConfigurationForDataSource() throws UnsupportedProviderException {
var system = mock(ApplicationProperties.System.class);
var datasource = mock(ApplicationProperties.Datasource.class);
var testUrl = "jdbc:h2:mem:test";
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getDatasource()).thenReturn(datasource);
when(datasource.isUseDefault()).thenReturn(true);
when(datasource.getDefaultUrl()).thenReturn(testUrl);
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.isUseDefault()).thenReturn(false);
when(datasource.getType()).thenReturn("postgresql");
when(datasource.getHostName()).thenReturn("localhost");
when(datasource.getPort()).thenReturn(5432);
when(datasource.getName()).thenReturn("postgres");
when(datasource.getUsername()).thenReturn("postgres");
when(datasource.getPassword()).thenReturn("postgres");
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 exceptionThrownWhenDBTypeIsUnsupported(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.isUseDefault()).thenReturn(false);
when(datasource.getType()).thenReturn(datasourceType);
assertThrows(UnsupportedProviderException.class, () -> databaseConfig.dataSource());
}
}

View File

@ -0,0 +1,60 @@
package stirling.software.SPDF.integrationtests;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import stirling.software.SPDF.SPDFApplication;
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;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Disabled
@SpringBootTest
public class SPDFApplicationIntegrationTest {
@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 (exists(settingsPath)) {
delete(settingsPath);
}
if (exists(customSettingsPath)) {
delete(customSettingsPath);
}
if (exists(staticPath)) {
delete(staticPath);
}
if (exists(templatesPath)) {
delete(templatesPath);
}
// Ensure the directories are created for testing
createDirectories(configPath);
createDirectories(staticPath);
createDirectories(templatesPath);
createFile(settingsPath);
createFile(customSettingsPath);
// Run the main method
SPDFApplication.main(new String[] {"-Dspring.profiles.active=default"});
// Verify that the directories were created
assertTrue(exists(settingsPath));
assertTrue(exists(customSettingsPath));
assertTrue(exists(staticPath));
assertTrue(exists(templatesPath));
}
}