feat(database,Jwt): relocate backups and Jwt-keys to config/backup and add Enterprise cleanup endpoints (#4225)

# Description of Changes

- **What was changed**
  - Centralized installation paths:
- Introduced `BACKUP_PATH`, `BACKUP_DB_PATH`, and
`BACKUP_PRIVATE_KEY_PATH` in `InstallationPathConfig`;
`getPrivateKeyPath()` now resolves to `backup/keys` and new
`getBackupPath()` returns `backup/db`.
- Removed old `PRIVATE_KEY_PATH` and switched all usages to the new
locations.
  - Database service enhancements:
- `DatabaseService` now uses `InstallationPathConfig.getBackupPath()`
and includes a one-time migration to move existing backups from
`config/db/backup` to `config/backup/db` (**@Deprecated(since = "2.0.0",
forRemoval = true)**).
- Added `deleteAllBackups()` and `deleteLastBackup()` methods and
exposed them via a new Enterprise controller.
  - New Enterprise-only API:
    - Added `DatabaseControllerEnterprise` with:
      - `DELETE /api/v1/database/deleteAll` — delete all backup files.
- `DELETE /api/v1/database/deleteLast` — delete the most recent backup.
- Endpoints gated by `@EnterpriseEndpoint` and
`@Conditional(H2SQLCondition.class)`.
  - Key persistence adjustments:
- `KeyPersistenceService` now migrates keys from `config/db/keys` to
`config/backup/keys` on startup (**@Deprecated(since = "2.0.0",
forRemoval = true)**).
  - Miscellaneous refactors/fixes:
- Switched driver resolution in `DatabaseConfig` to a switch expression.
    - Corrected HTTP status usage to `HttpStatus.SEE_OTHER`.
- Removed constructor `runningEE` flag from `AccountWebController` and
replaced EE checks with `@EnterpriseEndpoint`.
- Minor test and annotation improvements (e.g., `@Deprecated(since =
"0.45.0")`, method references, equals order).
  
- **Why the change was made**
- To standardize and future-proof storage locations for both backups and
keys under a clear `config/backup` hierarchy.
- To give Enterprise admins first-class, safe cleanup endpoints for
managing backup retention without manual file operations.
- To reduce conditional logic in controllers and rely on declarative EE
gating.
- To improve maintainability and correctness (status codes, switch
expression, null-safety patterns).

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ludy 2025-08-24 23:16:55 +02:00 committed by GitHub
parent 40cf337b23
commit 3af93f0adb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 252 additions and 29 deletions

View File

@ -14,18 +14,22 @@ public class InstallationPathConfig {
private static final String CONFIG_PATH;
private static final String CUSTOM_FILES_PATH;
private static final String CLIENT_WEBUI_PATH;
private static final String SCRIPTS_PATH;
private static final String PIPELINE_PATH;
// Config paths
private static final String SETTINGS_PATH;
private static final String CUSTOM_SETTINGS_PATH;
private static final String SCRIPTS_PATH;
private static final String BACKUP_PATH;
// Backup paths
private static final String BACKUP_DB_PATH;
private static final String BACKUP_PRIVATE_KEY_PATH;
// Custom file paths
private static final String STATIC_PATH;
private static final String TEMPLATES_PATH;
private static final String SIGNATURES_PATH;
private static final String PRIVATE_KEY_PATH;
static {
BASE_PATH = initializeBasePath();
@ -41,12 +45,16 @@ public class InstallationPathConfig {
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
CUSTOM_SETTINGS_PATH = CONFIG_PATH + "custom_settings.yml";
SCRIPTS_PATH = CONFIG_PATH + "scripts" + File.separator;
BACKUP_PATH = CONFIG_PATH + "backup" + File.separator;
// Initialize backup paths
BACKUP_DB_PATH = BACKUP_PATH + "db" + File.separator;
BACKUP_PRIVATE_KEY_PATH = BACKUP_PATH + "keys" + File.separator;
// Initialize custom file paths
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator;
SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator;
PRIVATE_KEY_PATH = CONFIG_PATH + "db" + File.separator + "keys" + File.separator;
}
private static String initializeBasePath() {
@ -124,6 +132,10 @@ public class InstallationPathConfig {
}
public static String getPrivateKeyPath() {
return PRIVATE_KEY_PATH;
return BACKUP_PRIVATE_KEY_PATH;
}
public static String getBackupPath() {
return BACKUP_DB_PATH;
}
}

View File

@ -179,7 +179,7 @@ class ApplicationPropertiesLogicTest {
assertEquals(30, t.getOcrMyPdfTimeoutMinutes());
}
@Deprecated
@Deprecated(since = "0.45.0")
@Test
void enterprise_metadata_defaults() {
ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition();

View File

@ -11,7 +11,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
@ -59,19 +58,16 @@ public class AccountWebController {
private final SessionPersistentRegistry sessionPersistentRegistry;
// Assuming you have a repository for user operations
private final UserRepository userRepository;
private final boolean runningEE;
private final TeamRepository teamRepository;
public AccountWebController(
ApplicationProperties applicationProperties,
SessionPersistentRegistry sessionPersistentRegistry,
UserRepository userRepository,
TeamRepository teamRepository,
@Qualifier("runningEE") boolean runningEE) {
TeamRepository teamRepository) {
this.applicationProperties = applicationProperties;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.userRepository = userRepository;
this.runningEE = runningEE;
this.teamRepository = teamRepository;
}
@ -207,11 +203,9 @@ public class AccountWebController {
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@EnterpriseEndpoint
@GetMapping("/usage")
public String showUsage() {
if (!runningEE) {
return "error";
}
return "usage";
}
@ -243,7 +237,7 @@ public class AccountWebController {
// Also check if user is part of the Internal team
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
shouldRemove = true;
}
@ -362,11 +356,9 @@ public class AccountWebController {
teamRepository.findAll().stream()
.filter(
team ->
!team.getName()
.equals(
stirling.software.proprietary.security
.service.TeamService
.INTERNAL_TEAM_NAME))
!stirling.software.proprietary.security.service.TeamService
.INTERNAL_TEAM_NAME
.equals(team.getName()))
.toList();
model.addAttribute("teams", allTeams);

View File

@ -134,21 +134,21 @@ public class DatabaseConfig {
ApplicationProperties.Driver driver =
ApplicationProperties.Driver.valueOf(driverName.toUpperCase());
switch (driver) {
return switch (driver) {
case H2 -> {
log.debug("H2 driver selected");
return DatabaseDriver.H2.getDriverClassName();
yield DatabaseDriver.H2.getDriverClassName();
}
case POSTGRESQL -> {
log.debug("Postgres driver selected");
return DatabaseDriver.POSTGRESQL.getDriverClassName();
yield DatabaseDriver.POSTGRESQL.getDriverClassName();
}
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,10 +7,10 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.eclipse.jetty.http.HttpStatus;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@ -145,7 +145,7 @@ public class DatabaseController {
.body(resource);
} catch (IOException e) {
log.error("Error downloading file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.SEE_OTHER_303)
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create("/database?error=downloadFailed"))
.build();
}

View File

@ -0,0 +1,101 @@
package stirling.software.proprietary.security.controller.api.enterprise;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.context.annotation.Conditional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.security.database.H2SQLCondition;
import stirling.software.proprietary.security.service.DatabaseService;
@Slf4j
@Controller
@RequestMapping("/api/v1/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@EnterpriseEndpoint
@Conditional(H2SQLCondition.class)
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
@RequiredArgsConstructor
public class DatabaseControllerEnterprise {
private final DatabaseService databaseService;
@Operation(
summary = "Delete the last database backup file",
description =
"Only Enterprise - Deletes the last database backup file from the server.")
@DeleteMapping("/deleteLast")
public ResponseEntity<?> deleteLastFile() {
log.info("Deleting last database backup file...");
List<Pair<FileInfo, Boolean>> results = databaseService.deleteLastBackup();
return getDeleteAllResults(results);
}
@Operation(
summary = "Delete all database backup files",
description = "Only Enterprise - Deletes all database backup files from the server.")
@DeleteMapping("/deleteAll")
public ResponseEntity<?> deleteAllFiles() {
log.info("Deleting all database backup files...");
List<Pair<FileInfo, Boolean>> results = databaseService.deleteAllBackups();
return getDeleteAllResults(results);
}
private ResponseEntity<?> getDeleteAllResults(List<Pair<FileInfo, Boolean>> results) {
if (results.isEmpty()) {
log.info("No backup files found to delete.");
return ResponseEntity.ok(new DeleteAllResult(List.of(), List.of(), "noContent"));
}
List<String> deleted =
results.stream()
.filter(p -> Boolean.TRUE.equals(p.getRight()))
.map(p -> p.getLeft().getFileName())
.toList();
List<String> failed =
results.stream()
.filter(p -> !Boolean.TRUE.equals(p.getRight()))
.map(p -> p.getLeft().getFileName())
.toList();
log.info("Deleted backup files: {}", deleted);
if (!failed.isEmpty()) {
log.warn("Some backup files could not be deleted: {}", failed);
return ResponseEntity.status(HttpStatus.MULTI_STATUS) // 207
.body(new DeleteAllResult(deleted, failed, "partialFailure"));
}
DeleteAllResult result = new DeleteAllResult(deleted, failed, "ok");
log.debug(
"DeleteAllResult: deleted={}, failed={}, status={}",
result.deleted,
result.failed,
result.status);
return ResponseEntity.ok(result); // 200
}
private static final class DeleteAllResult {
public final List<String> deleted;
public final List<String> failed;
public final String status;
public DeleteAllResult(List<String> deleted, List<String> failed, String status) {
this.deleted = deleted;
this.failed = failed;
this.status = status;
}
}
}

View File

@ -5,6 +5,7 @@ import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.sql.Connection;
import java.sql.PreparedStatement;
@ -21,6 +22,7 @@ import java.util.stream.Collectors;
import javax.sql.DataSource;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.jdbc.datasource.init.CannotReadScriptException;
import org.springframework.jdbc.datasource.init.ScriptException;
import org.springframework.stereotype.Service;
@ -45,10 +47,39 @@ public class DatabaseService implements DatabaseServiceInterface {
public DatabaseService(
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
this.BACKUP_DIR =
Paths.get(InstallationPathConfig.getConfigPath(), "db", "backup").normalize();
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
this.datasourceProps = datasourceProps;
this.dataSource = dataSource;
moveBackupFiles();
}
/** Move all backup files from db/backup to backup/db */
@Deprecated(since = "2.0.0", forRemoval = true)
private void moveBackupFiles() {
Path sourceDir =
Paths.get(InstallationPathConfig.getConfigPath(), "db", "backup").normalize();
if (!Files.exists(sourceDir)) {
log.info("Source directory does not exist: {}", sourceDir);
return;
}
try {
Files.createDirectories(BACKUP_DIR);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(sourceDir)) {
for (Path entry : stream) {
if (entry.getFileName().toString().startsWith(BACKUP_PREFIX)
&& entry.getFileName().toString().endsWith(SQL_SUFFIX)) {
Files.move(
entry,
BACKUP_DIR.resolve(entry.getFileName()),
StandardCopyOption.REPLACE_EXISTING);
}
}
}
} catch (IOException e) {
log.error("Error moving backup files: {}", e.getMessage(), e);
}
}
/**
@ -198,6 +229,46 @@ public class DatabaseService implements DatabaseServiceInterface {
}
}
@Override
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
List<FileInfo> backupList = this.getBackupList();
List<Pair<FileInfo, Boolean>> deletedFiles = new ArrayList<>();
for (FileInfo backup : backupList) {
try {
Files.deleteIfExists(Paths.get(backup.getFilePath()));
deletedFiles.add(Pair.of(backup, true));
} catch (IOException e) {
log.error("Error deleting backup file: {}", backup.getFileName(), e);
deletedFiles.add(Pair.of(backup, false));
}
}
return deletedFiles;
}
@Override
public List<Pair<FileInfo, Boolean>> deleteLastBackup() {
List<FileInfo> backupList = this.getBackupList();
List<Pair<FileInfo, Boolean>> deletedFiles = new ArrayList<>();
if (!backupList.isEmpty()) {
FileInfo lastBackup = backupList.get(backupList.size() - 1);
try {
Files.deleteIfExists(Paths.get(lastBackup.getFilePath()));
deletedFiles.add(Pair.of(lastBackup, true));
} catch (IOException e) {
log.error("Error deleting last backup file: {}", lastBackup.getFileName(), e);
deletedFiles.add(Pair.of(lastBackup, false));
}
}
return deletedFiles;
}
/**
* Deletes the oldest backup file from the specified list.
*
* @param filteredBackupList the list of backup files
*/
private static void deleteOldestBackup(List<FileInfo> filteredBackupList) {
try {
filteredBackupList.sort(
@ -237,6 +308,11 @@ public class DatabaseService implements DatabaseServiceInterface {
return version;
}
/*
* Checks if the current datasource is H2.
*
* @return true if the datasource is H2, false otherwise
*/
private boolean isH2Database() {
boolean isTypeH2 =
datasourceProps.getType().equalsIgnoreCase(ApplicationProperties.Driver.H2.name());
@ -301,6 +377,11 @@ public class DatabaseService implements DatabaseServiceInterface {
return filePath;
}
/**
* Executes a database script.
*
* @param scriptPath the path to the script file
*/
private void executeDatabaseScript(Path scriptPath) {
if (isH2Database()) {
String query = "RUNSCRIPT from ?;";

View File

@ -3,6 +3,8 @@ package stirling.software.proprietary.security.service;
import java.sql.SQLException;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import stirling.software.common.model.FileInfo;
import stirling.software.common.model.exception.UnsupportedProviderException;
@ -14,4 +16,8 @@ public interface DatabaseServiceInterface {
boolean hasBackup();
List<FileInfo> getBackupList();
List<Pair<FileInfo, Boolean>> deleteAllBackups();
List<Pair<FileInfo, Boolean>> deleteLastBackup();
}

View File

@ -1,9 +1,11 @@
package stirling.software.proprietary.security.service;
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.StandardCopyOption;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@ -52,6 +54,34 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
this.verifyingKeyCache = cacheManager.getCache("verifyingKeys");
}
/** Move all key files from db/keys to backup/keys */
@Deprecated(since = "2.0.0", forRemoval = true)
private void moveKeysToBackup() {
Path sourceDir =
Paths.get(InstallationPathConfig.getConfigPath(), "db", "keys").normalize();
if (!Files.exists(sourceDir)) {
log.info("Source directory does not exist: {}", sourceDir);
return;
}
Path targetDir = Paths.get(InstallationPathConfig.getPrivateKeyPath()).normalize();
try {
Files.createDirectories(targetDir);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(sourceDir)) {
for (Path entry : stream) {
Files.move(
entry,
targetDir.resolve(entry.getFileName()),
StandardCopyOption.REPLACE_EXISTING);
}
}
} catch (IOException e) {
log.error("Error moving key files to backup: {}", e.getMessage(), e);
}
}
@PostConstruct
public void initializeKeystore() {
if (!isKeystoreEnabled()) {
@ -59,6 +89,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
}
try {
moveKeysToBackup();
ensurePrivateKeyDirectoryExists();
loadKeyPair();
} catch (Exception e) {

View File

@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
import java.util.Map;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.actuate.audit.AuditEvent;
import org.springframework.boot.actuate.audit.AuditEventRepository;
import org.springframework.security.core.Authentication;
@ -29,8 +30,7 @@ public class AuditService {
public AuditService(
AuditEventRepository repository,
AuditConfigurationProperties auditConfig,
@org.springframework.beans.factory.annotation.Qualifier("runningEE")
boolean runningEE) {
@Qualifier("runningEE") boolean runningEE) {
this.repository = repository;
this.auditConfig = auditConfig;
this.runningEE = runningEE;