# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] 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)
- [ ] I have performed a self-review of my own code
- [ ] 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.

---------

Signed-off-by: dependabot[bot] <[email protected]>
Signed-off-by: Balázs Szücs <[email protected]>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: OUNZAR Aymane <[email protected]>
Co-authored-by: YAOU Reda <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Balázs Szücs <[email protected]>
Co-authored-by: Ludy <[email protected]>
Co-authored-by: tkymmm <[email protected]>
Co-authored-by: Peter Dave Hello <[email protected]>
Co-authored-by: albanobattistella <[email protected]>
Co-authored-by: PingLin8888 <[email protected]>
Co-authored-by: FdaSilvaYY <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: OteJlo <[email protected]>
Co-authored-by: Angel <[email protected]>
Co-authored-by: Ricardo Catarino <[email protected]>
Co-authored-by: Luis Antonio Argüelles González <[email protected]>
Co-authored-by: Dawid Urbański <[email protected]>
Co-authored-by: Stephan Paternotte <[email protected]>
Co-authored-by: Leonardo Santos Paulucio <[email protected]>
Co-authored-by: hamza khalem <[email protected]>
Co-authored-by: IT Creativity + Art Team <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: James Brunton <[email protected]>
Co-authored-by: Victor Villarreal <[email protected]>
This commit is contained in:
Anthony Stirling
2025-12-21 10:40:32 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh OUNZAR Aymane YAOU Reda dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Balázs Szücs Ludy tkymmm Peter Dave Hello albanobattistella PingLin8888 FdaSilvaYY Copilot OteJlo Angel Ricardo Catarino Luis Antonio Argüelles González Dawid Urbański Stephan Paternotte Leonardo Santos Paulucio hamza khalem IT Creativity + Art Team Reece Browne James Brunton Victor Villarreal
parent a5dcdd5bd9
commit 68ed54e398
343 changed files with 25208 additions and 6588 deletions
@@ -5,6 +5,7 @@ import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -307,8 +308,8 @@ public class AuditUtils {
// For HTTP methods, infer based on controller and path
if (httpMethod != null && path != null) {
String cls = controller.getSimpleName().toLowerCase();
String pkg = controller.getPackage().getName().toLowerCase();
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
@@ -374,8 +375,8 @@ public class AuditUtils {
}
// Otherwise infer from controller and path
String cls = controller.getSimpleName().toLowerCase();
String pkg = controller.getPackage().getName().toLowerCase();
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
@@ -134,7 +134,7 @@ public class ProprietaryUIDataController {
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
data.setEnableLogin(securityProps.isEnableLogin());
// Check if this is first-time setup with default credentials
// The isFirstLogin flag captures: default username/password usage and unchanged state
@@ -5,6 +5,7 @@ import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -150,7 +151,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
registrationId = oAuthToken.getAuthorizedClientRegistrationId();
// Redirect based on OAuth2 provider
switch (registrationId.toLowerCase()) {
switch (registrationId.toLowerCase(Locale.ROOT)) {
case "keycloak" -> {
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
@@ -151,7 +151,7 @@ public class InitialSecuritySetup {
if (internalApiUserOpt.isPresent()) {
User internalApiUser = internalApiUserOpt.get();
// move to team internal API user
if (!internalApiUser.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
if (!TeamService.INTERNAL_TEAM_NAME.equals(internalApiUser.getTeam().getName())) {
log.info(
"Moving internal API user to team: {}", TeamService.INTERNAL_TEAM_NAME);
Team internalTeam = teamService.getOrCreateInternalTeam();
@@ -1,5 +1,7 @@
package stirling.software.proprietary.security.configuration;
import java.util.Locale;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -133,7 +135,7 @@ public class DatabaseConfig {
private String getDriverClassName(String driverName) throws UnsupportedProviderException {
try {
ApplicationProperties.Driver driver =
ApplicationProperties.Driver.valueOf(driverName.toUpperCase());
ApplicationProperties.Driver.valueOf(driverName.toUpperCase(Locale.ROOT));
return switch (driver) {
case H2 -> {
@@ -5,6 +5,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.Locale;
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
import org.bouncycastle.crypto.signers.Ed25519Signer;
@@ -185,7 +186,7 @@ public class KeygenLicenseVerifier {
byte[] signatureBytes = Base64.getDecoder().decode(encodedSignature);
// Create the signing data format - prefix with "license/"
String signingData = String.format("license/%s", encryptedData);
String signingData = String.format(Locale.ROOT, "license/%s", encryptedData);
byte[] signingDataBytes = signingData.getBytes();
log.info("Signing data length: {} bytes", signingDataBytes.length);
@@ -348,7 +349,7 @@ public class KeygenLicenseVerifier {
.decode(encodedSignature.replace('-', '+').replace('_', '/'));
// For ED25519_SIGN format, the signing data is "key/" + encodedPayload
String signingData = String.format("key/%s", encodedPayload);
String signingData = String.format(Locale.ROOT, "key/%s", encodedPayload);
byte[] dataBytes = signingData.getBytes();
byte[] publicKeyBytes = Hex.decode(PUBLIC_KEY);
@@ -526,8 +527,10 @@ public class KeygenLicenseVerifier {
String licenseKey, String machineFingerprint, LicenseContext context) throws Exception {
String requestBody =
String.format(
Locale.ROOT,
"{\"meta\":{\"key\":\"%s\",\"scope\":{\"fingerprint\":\"%s\"}}}",
licenseKey, machineFingerprint);
licenseKey,
machineFingerprint);
HttpRequest request =
HttpRequest.newBuilder()
.uri(
@@ -0,0 +1,11 @@
package stirling.software.proprietary.security.database;
public interface DatabaseNotificationServiceInterface {
void notifyBackupsSuccess(String subject, String message);
void notifyBackupsFailure(String subject, String message);
void notifyImportsSuccess(String subject, String message);
void notifyImportsFailure(String subject, String message);
}
@@ -0,0 +1,75 @@
package stirling.software.proprietary.security.database.service;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.mail.MessagingException;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium.EnterpriseFeatures.DatabaseNotifications;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
import stirling.software.proprietary.security.service.EmailService;
@Service
@Slf4j
public class DatabaseNotificationService implements DatabaseNotificationServiceInterface {
private final Optional<EmailService> emailService;
private final ApplicationProperties props;
private final boolean runningEE;
private DatabaseNotifications notifications;
DatabaseNotificationService(
Optional<EmailService> emailService,
ApplicationProperties props,
@Qualifier("runningEE") boolean runningEE) {
this.emailService = emailService;
this.props = props;
this.runningEE = runningEE;
notifications = props.getPremium().getEnterpriseFeatures().getDatabaseNotifications();
}
@Override
public void notifyBackupsSuccess(String subject, String message) {
if (notifications.getBackups().isSuccessful() && runningEE) {
sendMail(subject, message);
}
}
@Override
public void notifyBackupsFailure(String subject, String message) {
if (notifications.getBackups().isFailed() && runningEE) {
sendMail(subject, message);
}
}
@Override
public void notifyImportsSuccess(String subject, String message) {
if (notifications.getImports().isSuccessful() && runningEE) {
sendMail(subject, message);
}
}
@Override
public void notifyImportsFailure(String subject, String message) {
if (notifications.getImports().isFailed() && runningEE) {
sendMail(subject, message);
}
}
private void sendMail(String subject, String message) {
emailService.ifPresent(
service -> {
try {
String to = props.getMail().getFrom();
service.sendSimpleMail(to, subject, message);
} catch (MessagingException e) {
log.error("Error sending notification email: {}", e.getMessage(), e);
}
});
}
}
@@ -18,7 +18,8 @@ public class AttemptCounter {
}
public boolean shouldReset(long attemptIncrementTime) {
return System.currentTimeMillis() - lastAttemptTime > attemptIncrementTime;
long elapsed = System.currentTimeMillis() - lastAttemptTime;
return elapsed >= attemptIncrementTime;
}
public void reset() {
@@ -4,6 +4,7 @@ import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -129,7 +130,7 @@ public class User implements UserDetails, Serializable {
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
this.authenticationType = authenticationType.toString().toLowerCase(Locale.ROOT);
}
public void addAuthorities(Set<Authority> authorities) {
@@ -7,6 +7,7 @@ import static stirling.software.common.util.ValidationUtils.isStringEmpty;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
@@ -197,7 +198,7 @@ public class OAuth2Configuration {
String name = oauth.getProvider();
String firstChar = String.valueOf(name.charAt(0));
String clientName = name.replaceFirst(firstChar, firstChar.toUpperCase());
String clientName = name.replaceFirst(firstChar, firstChar.toUpperCase(Locale.ROOT));
Provider oidcProvider =
new Provider(
@@ -207,7 +208,8 @@ public class OAuth2Configuration {
oauth.getClientId(),
oauth.getClientSecret(),
oauth.getScopes(),
UsernameAttribute.valueOf(oauth.getUseAsUsername().toUpperCase()),
UsernameAttribute.valueOf(
oauth.getUseAsUsername().toUpperCase(Locale.ROOT)),
null,
null,
null);
@@ -27,7 +27,7 @@ class AppUpdateAuthService implements ShowAdminInterface {
if (!showUpdate) {
return showUpdate;
}
boolean showUpdateOnlyAdmin = applicationProperties.getSystem().getShowUpdateOnlyAdmin();
boolean showUpdateOnlyAdmin = applicationProperties.getSystem().isShowUpdateOnlyAdmin();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return !showUpdateOnlyAdmin;
@@ -1,5 +1,7 @@
package stirling.software.proprietary.security.service;
import java.util.Locale;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -60,7 +62,7 @@ public class CustomUserDetailsService implements UserDetailsService {
}
AuthenticationType userAuthenticationType =
AuthenticationType.valueOf(authTypeStr.toUpperCase());
AuthenticationType.valueOf(authTypeStr.toUpperCase(Locale.ROOT));
if (!user.hasPassword() && userAuthenticationType == AuthenticationType.WEB) {
throw new IllegalArgumentException("Password must not be null");
}
@@ -7,7 +7,10 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -32,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
import stirling.software.proprietary.security.model.exception.BackupNotFoundException;
@Slf4j
@@ -44,12 +48,16 @@ public class DatabaseService implements DatabaseServiceInterface {
private final ApplicationProperties.Datasource datasourceProps;
private final DataSource dataSource;
private final DatabaseNotificationServiceInterface backupNotificationService;
public DatabaseService(
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
ApplicationProperties.Datasource datasourceProps,
DataSource dataSource,
DatabaseNotificationServiceInterface backupNotificationService) {
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
this.datasourceProps = datasourceProps;
this.dataSource = dataSource;
this.backupNotificationService = backupNotificationService;
moveBackupFiles();
}
@@ -172,6 +180,8 @@ public class DatabaseService implements DatabaseServiceInterface {
public boolean importDatabaseFromUI(String fileName) {
try {
importDatabaseFromUI(getBackupFilePath(fileName));
backupNotificationService.notifyImportsSuccess(
"Database import completed", "Import file: " + fileName);
return true;
} catch (IOException e) {
log.error(
@@ -179,6 +189,9 @@ public class DatabaseService implements DatabaseServiceInterface {
fileName,
e.getMessage(),
e.getCause());
backupNotificationService.notifyImportsFailure(
"Database import failed",
"Import file: " + fileName + " Message: " + e.getMessage());
return false;
}
}
@@ -219,16 +232,59 @@ public class DatabaseService implements DatabaseServiceInterface {
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, insertOutputFilePath.toString());
stmt.execute();
backupNotificationService.notifyBackupsSuccess(
"Database backup export completed",
"Backup file: " + insertOutputFilePath.getFileName());
} catch (SQLException e) {
log.error("Error during database export: {}", e.getMessage(), e);
backupNotificationService.notifyBackupsFailure(
"Database backup export failed",
"Backup file: "
+ insertOutputFilePath.getFileName()
+ " Message: "
+ e.getMessage());
} catch (CannotReadScriptException e) {
log.error("Error during database export: File {} not found", insertOutputFilePath);
backupNotificationService.notifyBackupsFailure(
"Database backup export failed",
"Error during database export: File "
+ insertOutputFilePath.getFileName()
+ " not found. Message: "
+ e.getMessage());
}
log.info("Database export completed: {}", insertOutputFilePath);
verifyBackup(insertOutputFilePath);
}
}
private boolean verifyBackup(Path backupPath) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] content = Files.readAllBytes(backupPath);
String checksum = bytesToHex(digest.digest(content));
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
stmt.setString(1, backupPath.toString());
stmt.execute();
}
return true;
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
}
return false;
}
private String bytesToHex(byte[] hash) {
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
}
@Override
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
List<FileInfo> backupList = this.getBackupList();
@@ -384,6 +440,12 @@ public class DatabaseService implements DatabaseServiceInterface {
*/
private void executeDatabaseScript(Path scriptPath) {
if (isH2Database()) {
if (!verifyBackup(scriptPath)) {
log.error("Backup verification failed for: {}", scriptPath);
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
}
String query = "RUNSCRIPT from ?;";
try (Connection conn = dataSource.getConnection();
@@ -11,6 +11,7 @@ import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.api.Email;
@@ -20,6 +21,7 @@ import stirling.software.proprietary.security.model.api.Email;
* JavaMailSender to send the email and is designed to handle both the message content and file
* attachments.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
@@ -72,6 +74,40 @@ public class EmailService {
// Sends the email via the configured mail sender
mailSender.send(message);
log.debug(
"Email sent successfully to {} with subject: {} body: {}",
email.getTo(),
email.getSubject(),
email.getBody());
}
/**
* Sends a simple email without attachments asynchronously.
*
* @param to the recipient address
* @param subject subject line
* @param body message body
* @throws MessagingException if sending fails or address is invalid
*/
@Async
public void sendSimpleMail(String to, String subject, String body) throws MessagingException {
if (to == null || to.trim().isEmpty()) {
throw new MessagingException("Invalid Addresses");
}
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false);
helper.addTo(to);
helper.setSubject(subject);
helper.setText(body, false);
helper.setFrom(mailProperties.getFrom());
mailSender.send(message);
log.debug(
"Simple email sent successfully to {} with subject: {} body: {}",
to,
subject,
body);
}
/**
@@ -1,5 +1,6 @@
package stirling.software.proprietary.security.service;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@@ -45,17 +46,19 @@ public class LoginAttemptService {
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
return;
}
attemptsCache.remove(key.toLowerCase());
String normalizedKey = key.toLowerCase(Locale.ROOT);
attemptsCache.remove(normalizedKey);
}
public void loginFailed(String key) {
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
return;
}
AttemptCounter attemptCounter = attemptsCache.get(key.toLowerCase());
String normalizedKey = key.toLowerCase(Locale.ROOT);
AttemptCounter attemptCounter = attemptsCache.get(normalizedKey);
if (attemptCounter == null) {
attemptCounter = new AttemptCounter();
attemptsCache.put(key.toLowerCase(), attemptCounter);
attemptsCache.put(normalizedKey, attemptCounter);
} else {
if (attemptCounter.shouldReset(ATTEMPT_INCREMENT_TIME)) {
attemptCounter.reset();
@@ -68,7 +71,8 @@ public class LoginAttemptService {
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
return false;
}
AttemptCounter attemptCounter = attemptsCache.get(key.toLowerCase());
String normalizedKey = key.toLowerCase(Locale.ROOT);
AttemptCounter attemptCounter = attemptsCache.get(normalizedKey);
if (attemptCounter == null) {
return false;
}
@@ -80,7 +84,8 @@ public class LoginAttemptService {
// Arbitrarily high number if tracking is disabled
return Integer.MAX_VALUE;
}
AttemptCounter attemptCounter = attemptsCache.get(key.toLowerCase());
String normalizedKey = key.toLowerCase(Locale.ROOT);
AttemptCounter attemptCounter = attemptsCache.get(normalizedKey);
if (attemptCounter == null) {
return MAX_ATTEMPT;
}
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -145,6 +146,7 @@ public class UserService implements UserServiceInterface {
return addApiKeyToUser(username);
}
@Override
public String getApiKeyForUser(String username) {
User user =
findByUsernameIgnoreCase(username)
@@ -581,9 +583,10 @@ public class UserService implements UserServiceInterface {
.matches();
List<String> notAllowedUserList = new ArrayList<>();
notAllowedUserList.add("ALL_USERS".toLowerCase());
notAllowedUserList.add("ALL_USERS".toLowerCase(Locale.ROOT));
notAllowedUserList.add("anonymoususer");
boolean notAllowedUser = notAllowedUserList.contains(username.toLowerCase());
String normalizedUsername = username.toLowerCase(Locale.ROOT);
boolean notAllowedUser = notAllowedUserList.contains(normalizedUsername);
return (isValidSimpleUsername || isValidEmail) && !notAllowedUser;
}
@@ -631,6 +634,7 @@ public class UserService implements UserServiceInterface {
}
}
@Override
public String getCurrentUsername() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
@@ -717,6 +721,7 @@ public class UserService implements UserServiceInterface {
}
}
@Override
public long getTotalUsersCount() {
// Count all users in the database
long userCount = userRepository.count();
@@ -1,345 +0,0 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@UtilityClass
public class FormCopyUtils {
public boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Temporarily set NeedAppearances to true during field creation
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (rowIndex >= rows) {
continue;
}
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Generate appearance streams and embed them authoritatively
boolean appearancesGenerated = false;
try {
newAcroForm.refreshAppearances();
appearancesGenerated = true;
} catch (NoSuchMethodError nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; "
+ "leaving NeedAppearances=true for viewer-side rendering.");
} catch (Exception t) {
log.warn(
"Failed to refresh field appearances via AcroForm: {}. "
+ "Leaving NeedAppearances=true as fallback.",
t.getMessage(),
t);
}
// After successful appearance generation, set NeedAppearances to false
// to signal that appearance streams are now embedded authoritatively
if (appearancesGenerated) {
try {
newAcroForm.setNeedAppearances(false);
} catch (Exception e) {
log.debug(
"Failed to set NeedAppearances to false: {}. "
+ "Appearances were generated but flag could not be updated.",
e.getMessage());
}
}
}
private void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (!(sourceField instanceof PDTerminalField terminalField)) {
continue;
}
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
if (handler == null) {
log.debug(
"Skipping unsupported field type '{}' for widget '{}'",
sourceField.getClass().getSimpleName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName));
continue;
}
copyFieldUsingHandler(
handler,
terminalField,
newAcroForm,
destinationPage,
destinationAnnotations,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private void copyFieldUsingHandler(
FormFieldTypeSupport handler,
PDTerminalField sourceField,
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTerminalField newField = handler.createField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newField,
sourceField.getPartialName(),
handler.fallbackWidgetName(),
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
handler.copyFromOriginal(sourceField, newField);
} catch (Exception e) {
log.warn(
"Failed to copy {} field '{}': {}",
handler.typeName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName),
e.getMessage(),
e);
}
}
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget != null) {
map.put(widget, field);
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -1690,9 +1690,9 @@ public class FormUtils {
acroForm.getFields().add(field);
}
// Delegation methods to FormCopyUtils for form field transformation
// Delegation methods to GeneralFormCopyUtils for form field transformation
public boolean hasAnyRotatedPage(PDDocument document) {
return FormCopyUtils.hasAnyRotatedPage(document);
return stirling.software.common.util.GeneralFormCopyUtils.hasAnyRotatedPage(document);
}
public void copyAndTransformFormFields(
@@ -1705,7 +1705,7 @@ public class FormUtils {
float cellWidth,
float cellHeight)
throws IOException {
FormCopyUtils.copyAndTransformFormFields(
stirling.software.common.util.GeneralFormCopyUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,