Security fixes, enterprise stuff and more (#3241)

# Description of Changes

Please provide a summary of the changes, including:

- Enable user to add custom JAVA ops with env JAVA_CUSTOM_OPTS
- Added support for prometheus (enabled via JAVA_CUSTOM_OPTS +
enterprise license)
- Changed settings from enterprise naming to 'Premium'
- KeygenLicense Check to support offline licenses
- Disable URL-to-PDF due to huge security bug
- Remove loud Split PDF logs
- addUsers renamed to adminSettings
- Added Usage analytics page
- Add user button to only be enabled based on total users free
- Improve Merge memory usage


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/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/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/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/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
Co-authored-by: Connor Yoh <con.yoh13@gmail.com>
This commit is contained in:
Anthony Stirling 2025-03-25 17:57:17 +00:00 committed by GitHub
parent 86becc61de
commit e151286337
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1603 additions and 267 deletions

View File

@ -25,20 +25,16 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, conver
# Set Environment Variables # Set Environment Variables
ENV DOCKER_ENABLE_SECURITY=false \ ENV DOCKER_ENABLE_SECURITY=false \
VERSION_TAG=$VERSION_TAG \ VERSION_TAG=$VERSION_TAG \
JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions \ JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
-XX:MaxRAMPercentage=75 \ JAVA_CUSTOM_OPTS="" \
-XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent \
-XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication \
-XX:G1PeriodicGCSystemLoadThreshold=70" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \
PUID=1000 \ PUID=1000 \
PGID=1000 \ PGID=1000 \
UMASK=022 \ UMASK=022 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \ UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin
# JDK for app # JDK for app
@ -77,7 +73,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
py3-pillow@testing \ py3-pillow@testing \
py3-pdf2image@testing && \ py3-pdf2image@testing && \
python3 -m venv /opt/venv && \ python3 -m venv /opt/venv && \
export PATH="/opt/venv/bin:$PATH" && \
pip install --upgrade pip && \ pip install --upgrade pip && \
pip install --no-cache-dir --upgrade unoserver weasyprint && \ pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \

View File

@ -32,13 +32,8 @@ ARG VERSION_TAG
# Set Environment Variables # Set Environment Variables
ENV DOCKER_ENABLE_SECURITY=false \ ENV DOCKER_ENABLE_SECURITY=false \
VERSION_TAG=$VERSION_TAG \ VERSION_TAG=$VERSION_TAG \
JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions \ JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
-XX:MaxRAMPercentage=75 \ JAVA_CUSTOM_OPTS="" \
-XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent \
-XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication \
-XX:G1PeriodicGCSystemLoadThreshold=70" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \
PUID=1000 \ PUID=1000 \
PGID=1000 \ PGID=1000 \
@ -47,7 +42,8 @@ ENV DOCKER_ENABLE_SECURITY=false \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \ INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \ UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin
# JDK for app # JDK for app
@ -87,7 +83,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
py3-pillow@testing \ py3-pillow@testing \
py3-pdf2image@testing && \ py3-pdf2image@testing && \
python3 -m venv /opt/venv && \ python3 -m venv /opt/venv && \
export PATH="/opt/venv/bin:$PATH" && \
pip install --upgrade pip && \ pip install --upgrade pip && \
pip install --no-cache-dir --upgrade unoserver weasyprint && \ pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \

View File

@ -7,13 +7,8 @@ ARG VERSION_TAG
ENV DOCKER_ENABLE_SECURITY=false \ ENV DOCKER_ENABLE_SECURITY=false \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG \ VERSION_TAG=$VERSION_TAG \
JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions \ JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
-XX:MaxRAMPercentage=75 \ JAVA_CUSTOM_OPTS="" \
-XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent \
-XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication \
-XX:G1PeriodicGCSystemLoadThreshold=70" \
PUID=1000 \ PUID=1000 \
PGID=1000 \ PGID=1000 \
UMASK=022 UMASK=022

View File

@ -25,7 +25,7 @@ ext {
} }
group = "stirling.software" group = "stirling.software"
version = "0.44.3" version = "0.45.0"
java { java {
// 17 is lowest but we support and recommend 21 // 17 is lowest but we support and recommend 21
@ -328,9 +328,13 @@ dependencies {
implementation "org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion" implementation "org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion"
implementation 'com.posthog.java:posthog:1.2.0' implementation 'com.posthog.java:posthog:1.2.0'
implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") { if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion" implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion"
implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE" implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE"
implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion" implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion"

View File

@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}"
echo "running with JAVA_TOOL_OPTIONS ${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}"
# Update the user and group IDs as per environment variables # Update the user and group IDs as per environment variables
if [ ! -z "$PUID" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then if [ ! -z "$PUID" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then
usermod -o -u "$PUID" stirlingpdfuser || true usermod -o -u "$PUID" stirlingpdfuser || true

View File

@ -8,6 +8,8 @@ import org.springframework.core.annotation.Order;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.SPDF.model.ApplicationProperties.Premium;
@Configuration @Configuration
@Order(Ordered.HIGHEST_PRECEDENCE) @Order(Ordered.HIGHEST_PRECEDENCE)
@ -22,6 +24,7 @@ public class EEAppConfig {
ApplicationProperties applicationProperties, LicenseKeyChecker licenseKeyChecker) { ApplicationProperties applicationProperties, LicenseKeyChecker licenseKeyChecker) {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
this.licenseKeyChecker = licenseKeyChecker; this.licenseKeyChecker = licenseKeyChecker;
migrateEnterpriseSettingsToPremium(this.applicationProperties);
} }
@Bean(name = "runningEE") @Bean(name = "runningEE")
@ -31,6 +34,74 @@ public class EEAppConfig {
@Bean(name = "SSOAutoLogin") @Bean(name = "SSOAutoLogin")
public boolean ssoAutoLogin() { public boolean ssoAutoLogin() {
return applicationProperties.getEnterpriseEdition().isSsoAutoLogin(); return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
// TODO: Remove post migration
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
EnterpriseEdition enterpriseEdition = applicationProperties.getEnterpriseEdition();
Premium premium = applicationProperties.getPremium();
// Only proceed if both objects exist
if (enterpriseEdition == null || premium == null) {
return;
}
// Copy the license key if it's set in enterprise but not in premium
if (premium.getKey() == null
|| premium.getKey().equals("00000000-0000-0000-0000-000000000000")) {
if (enterpriseEdition.getKey() != null
&& !enterpriseEdition.getKey().equals("00000000-0000-0000-0000-000000000000")) {
premium.setKey(enterpriseEdition.getKey());
}
}
// Copy enabled state if enterprise is enabled but premium is not
if (!premium.isEnabled() && enterpriseEdition.isEnabled()) {
premium.setEnabled(true);
}
// Copy SSO auto login setting
if (!premium.getProFeatures().isSsoAutoLogin() && enterpriseEdition.isSsoAutoLogin()) {
premium.getProFeatures().setSsoAutoLogin(true);
}
// Copy CustomMetadata settings
Premium.ProFeatures.CustomMetadata premiumMetadata =
premium.getProFeatures().getCustomMetadata();
EnterpriseEdition.CustomMetadata enterpriseMetadata = enterpriseEdition.getCustomMetadata();
if (enterpriseMetadata != null && premiumMetadata != null) {
// Copy autoUpdateMetadata setting
if (!premiumMetadata.isAutoUpdateMetadata()
&& enterpriseMetadata.isAutoUpdateMetadata()) {
premiumMetadata.setAutoUpdateMetadata(true);
}
// Copy author if not set in premium but set in enterprise
if ((premiumMetadata.getAuthor() == null
|| premiumMetadata.getAuthor().trim().isEmpty()
|| "username".equals(premiumMetadata.getAuthor()))
&& enterpriseMetadata.getAuthor() != null
&& !enterpriseMetadata.getAuthor().trim().isEmpty()) {
premiumMetadata.setAuthor(enterpriseMetadata.getAuthor());
}
// Copy creator if not set in premium but set in enterprise and different from default
if ((premiumMetadata.getCreator() == null
|| "Stirling-PDF".equals(premiumMetadata.getCreator()))
&& enterpriseMetadata.getCreator() != null
&& !"Stirling-PDF".equals(enterpriseMetadata.getCreator())) {
premiumMetadata.setCreator(enterpriseMetadata.getCreator());
}
// Copy producer if not set in premium but set in enterprise and different from default
if ((premiumMetadata.getProducer() == null
|| "Stirling-PDF".equals(premiumMetadata.getProducer()))
&& enterpriseMetadata.getProducer() != null
&& !"Stirling-PDF".equals(enterpriseMetadata.getProducer())) {
premiumMetadata.setProducer(enterpriseMetadata.getProducer());
}
}
} }
} }

View File

@ -4,12 +4,17 @@ import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.util.Base64;
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
import org.bouncycastle.crypto.signers.Ed25519Signer;
import org.bouncycastle.util.encoders.Hex;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.posthog.java.shaded.org.json.JSONException;
import com.posthog.java.shaded.org.json.JSONObject; import com.posthog.java.shaded.org.json.JSONObject;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -20,11 +25,19 @@ import stirling.software.SPDF.utils.GeneralUtils;
@Service @Service
@Slf4j @Slf4j
public class KeygenLicenseVerifier { public class KeygenLicenseVerifier {
// todo: place in config files? // License verification configuration
private static final String ACCOUNT_ID = "e5430f69-e834-4ae4-befd-b602aae5f372"; private static final String ACCOUNT_ID = "e5430f69-e834-4ae4-befd-b602aae5f372";
private static final String BASE_URL = "https://api.keygen.sh/v1/accounts"; private static final String BASE_URL = "https://api.keygen.sh/v1/accounts";
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final String PUBLIC_KEY =
"9fbc0d78593dcfcf03c945146edd60083bf5fae77dbc08aaa3935f03ce94a58d";
private static final String CERT_PREFIX = "-----BEGIN LICENSE FILE-----";
private static final String CERT_SUFFIX = "-----END LICENSE FILE-----";
private static final String JWT_PREFIX = "key/";
private static final ObjectMapper objectMapper = new ObjectMapper();
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@Autowired @Autowired
@ -32,9 +45,367 @@ public class KeygenLicenseVerifier {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
} }
public boolean verifyLicense(String licenseKey) { public boolean verifyLicense(String licenseKeyOrCert) {
if (isCertificateLicense(licenseKeyOrCert)) {
log.info("Detected certificate-based license. Processing...");
return verifyCertificateLicense(licenseKeyOrCert);
} else if (isJWTLicense(licenseKeyOrCert)) {
log.info("Detected JWT-style license key. Processing...");
return verifyJWTLicense(licenseKeyOrCert);
} else {
log.info("Detected standard license key. Processing...");
return verifyStandardLicense(licenseKeyOrCert);
}
}
private boolean isCertificateLicense(String license) {
return license != null && license.trim().startsWith(CERT_PREFIX);
}
private boolean isJWTLicense(String license) {
return license != null && license.trim().startsWith(JWT_PREFIX);
}
private boolean verifyCertificateLicense(String licenseFile) {
try { try {
log.info("Checking license key"); log.info("Verifying certificate-based license");
String encodedPayload = licenseFile;
// Remove the header
encodedPayload = encodedPayload.replace(CERT_PREFIX, "");
// Remove the footer
encodedPayload = encodedPayload.replace(CERT_SUFFIX, "");
// Remove all newlines
encodedPayload = encodedPayload.replaceAll("\\r?\\n", "");
byte[] payloadBytes = Base64.getDecoder().decode(encodedPayload);
String payload = new String(payloadBytes);
log.info("Decoded certificate payload: {}", payload);
String encryptedData = "";
String encodedSignature = "";
String algorithm = "";
try {
JSONObject attrs = new JSONObject(payload);
encryptedData = (String) attrs.get("enc");
encodedSignature = (String) attrs.get("sig");
algorithm = (String) attrs.get("alg");
log.info("Certificate algorithm: {}", algorithm);
} catch (JSONException e) {
log.error("Failed to parse license file: {}", e.getMessage());
return false;
}
// Verify license file algorithm
if (!algorithm.equals("base64+ed25519")) {
log.error(
"Unsupported algorithm: {}. Only base64+ed25519 is supported.", algorithm);
return false;
}
// Verify signature
boolean isSignatureValid = verifyEd25519Signature(encryptedData, encodedSignature);
if (!isSignatureValid) {
log.error("License file signature is invalid");
return false;
}
log.info("License file signature is valid");
// Decode the base64 data
String decodedData;
try {
decodedData = new String(Base64.getDecoder().decode(encryptedData));
} catch (IllegalArgumentException e) {
log.error("Failed to decode license data: {}", e.getMessage());
return false;
}
// Process the certificate data
boolean isValid = processCertificateData(decodedData);
return isValid;
} catch (Exception e) {
log.error("Error verifying certificate license: {}", e.getMessage(), e);
return false;
}
}
private boolean verifyEd25519Signature(String encryptedData, String encodedSignature) {
try {
log.info("Signature to verify: {}", encodedSignature);
log.info("Public key being used: {}", PUBLIC_KEY);
byte[] signatureBytes = Base64.getDecoder().decode(encodedSignature);
// Create the signing data format - prefix with "license/"
String signingData = String.format("license/%s", encryptedData);
byte[] signingDataBytes = signingData.getBytes();
log.info("Signing data length: {} bytes", signingDataBytes.length);
byte[] publicKeyBytes = Hex.decode(PUBLIC_KEY);
Ed25519PublicKeyParameters verifierParams =
new Ed25519PublicKeyParameters(publicKeyBytes, 0);
Ed25519Signer verifier = new Ed25519Signer();
verifier.init(false, verifierParams);
verifier.update(signingDataBytes, 0, signingDataBytes.length);
// Verify the signature
boolean result = verifier.verifySignature(signatureBytes);
if (!result) {
log.error("Signature verification failed with standard public key");
}
return result;
} catch (Exception e) {
log.error("Error verifying Ed25519 signature: {}", e.getMessage(), e);
return false;
}
}
private boolean processCertificateData(String certData) {
try {
log.info("Processing certificate data: {}", certData);
JSONObject licenseData = new JSONObject(certData);
JSONObject metaObj = licenseData.optJSONObject("meta");
if (metaObj != null) {
String issuedStr = metaObj.optString("issued", null);
String expiryStr = metaObj.optString("expiry", null);
if (issuedStr != null && expiryStr != null) {
java.time.Instant issued = java.time.Instant.parse(issuedStr);
java.time.Instant expiry = java.time.Instant.parse(expiryStr);
java.time.Instant now = java.time.Instant.now();
if (issued.isAfter(now)) {
log.error(
"License file issued date is in the future. Please adjust system time or request a new license");
return false;
}
// Check if the license file has expired
if (expiry.isBefore(now)) {
log.error("License file has expired on {}", expiryStr);
return false;
}
log.info("License file valid until {}", expiryStr);
}
}
// Get the main license data
JSONObject dataObj = licenseData.optJSONObject("data");
if (dataObj == null) {
log.error("No data object found in certificate");
return false;
}
// Extract license or machine information
JSONObject attributesObj = dataObj.optJSONObject("attributes");
if (attributesObj != null) {
log.info("Found attributes in certificate data");
// Extract metadata
JSONObject metadataObj = attributesObj.optJSONObject("metadata");
if (metadataObj != null) {
int users = metadataObj.optInt("users", 0);
if (users > 0) {
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
}
}
// Check maxUsers directly in attributes if present from policy definition
// if (attributesObj.has("maxUsers")) {
// int maxUsers = attributesObj.optInt("maxUsers", 0);
// if (maxUsers > 0) {
// applicationProperties.getPremium().setMaxUsers(maxUsers);
// log.info("License directly specifies {} max users",
// maxUsers);
// }
// }
// Check license status if available
String status = attributesObj.optString("status", null);
if (status != null
&& !status.equals("ACTIVE")
&& !status.equals("EXPIRING")) { // Accept "EXPIRING" status as valid
log.error("License status is not active: {}", status);
return false;
}
}
return true;
} catch (Exception e) {
log.error("Error processing certificate data: {}", e.getMessage(), e);
return false;
}
}
private boolean verifyJWTLicense(String licenseKey) {
try {
log.info("Verifying ED25519_SIGN format license key");
// Remove the "key/" prefix
String licenseData = licenseKey.substring(JWT_PREFIX.length());
// Split into payload and signature
String[] parts = licenseData.split("\\.", 2);
if (parts.length != 2) {
log.error(
"Invalid ED25519_SIGN license format. Expected format: key/payload.signature");
return false;
}
String encodedPayload = parts[0];
String encodedSignature = parts[1];
// Verify signature
boolean isSignatureValid = verifyJWTSignature(encodedPayload, encodedSignature);
if (!isSignatureValid) {
log.error("ED25519_SIGN license signature is invalid");
return false;
}
log.info("ED25519_SIGN license signature is valid");
// Decode and process payload - first convert from URL-safe base64 if needed
String base64Payload = encodedPayload.replace('-', '+').replace('_', '/');
byte[] payloadBytes = Base64.getDecoder().decode(base64Payload);
String payload = new String(payloadBytes);
// Process the license payload
boolean isValid = processJWTLicensePayload(payload);
return isValid;
} catch (Exception e) {
log.error("Error verifying ED25519_SIGN license: {}", e.getMessage());
return false;
}
}
private boolean verifyJWTSignature(String encodedPayload, String encodedSignature) {
try {
// Decode base64 signature
byte[] signatureBytes =
Base64.getDecoder()
.decode(encodedSignature.replace('-', '+').replace('_', '/'));
// For ED25519_SIGN format, the signing data is "key/" + encodedPayload
String signingData = String.format("key/%s", encodedPayload);
byte[] dataBytes = signingData.getBytes();
byte[] publicKeyBytes = Hex.decode(PUBLIC_KEY);
Ed25519PublicKeyParameters verifierParams =
new Ed25519PublicKeyParameters(publicKeyBytes, 0);
Ed25519Signer verifier = new Ed25519Signer();
verifier.init(false, verifierParams);
verifier.update(dataBytes, 0, dataBytes.length);
// Verify the signature
return verifier.verifySignature(signatureBytes);
} catch (Exception e) {
log.error("Error verifying JWT signature: {}", e.getMessage());
return false;
}
}
private boolean processJWTLicensePayload(String payload) {
try {
log.info("Processing license payload: {}", payload);
JSONObject licenseData = new JSONObject(payload);
JSONObject licenseObj = licenseData.optJSONObject("license");
if (licenseObj == null) {
String id = licenseData.optString("id", null);
if (id != null) {
log.info("Found license ID: {}", id);
licenseObj = licenseData; // Use the root object as the license object
} else {
log.error("License data not found in payload");
return false;
}
}
String licenseId = licenseObj.optString("id", "unknown");
log.info("Processing license with ID: {}", licenseId);
// Check expiry date
String expiryStr = licenseObj.optString("expiry", null);
if (expiryStr != null && !expiryStr.equals("null")) {
java.time.Instant expiry = java.time.Instant.parse(expiryStr);
java.time.Instant now = java.time.Instant.now();
if (now.isAfter(expiry)) {
log.error("License has expired on {}", expiryStr);
return false;
}
log.info("License valid until {}", expiryStr);
} else {
log.info("License has no expiration date");
}
// Extract account, product, policy info
JSONObject accountObj = licenseData.optJSONObject("account");
if (accountObj != null) {
String accountId = accountObj.optString("id", "unknown");
log.info("License belongs to account: {}", accountId);
// Verify this matches your expected account ID
if (!ACCOUNT_ID.equals(accountId)) {
log.warn("License account ID does not match expected account ID");
// You might want to fail verification here depending on your requirements
}
}
// Extract policy information if available
JSONObject policyObj = licenseData.optJSONObject("policy");
if (policyObj != null) {
String policyId = policyObj.optString("id", "unknown");
log.info("License uses policy: {}", policyId);
// Extract max users from policy if available (customize based on your policy
// structure)
int users = policyObj.optInt("users", 0);
if (users > 0) {
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
} else {
// Try to get users from metadata if present
Object metadataObj = policyObj.opt("metadata");
if (metadataObj instanceof JSONObject) {
JSONObject metadata = (JSONObject) metadataObj;
users = metadata.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users (from metadata)", users);
} else {
// Default value
applicationProperties.getPremium().setMaxUsers(1);
log.info("Using default of 1 user for license");
}
}
}
return true;
} catch (Exception e) {
log.error("Error processing license payload: {}", e.getMessage(), e);
return false;
}
}
private boolean verifyStandardLicense(String licenseKey) {
try {
log.info("Checking standard license key");
String machineFingerprint = generateMachineFingerprint(); String machineFingerprint = generateMachineFingerprint();
// First, try to validate the license // First, try to validate the license
@ -44,7 +415,7 @@ public class KeygenLicenseVerifier {
String licenseId = validationResponse.path("data").path("id").asText(); String licenseId = validationResponse.path("data").path("id").asText();
if (!isValid) { if (!isValid) {
String code = validationResponse.path("meta").path("code").asText(); String code = validationResponse.path("meta").path("code").asText();
log.debug(code); log.info(code);
if ("NO_MACHINE".equals(code) if ("NO_MACHINE".equals(code)
|| "NO_MACHINES".equals(code) || "NO_MACHINES".equals(code)
|| "FINGERPRINT_SCOPE_MISMATCH".equals(code)) { || "FINGERPRINT_SCOPE_MISMATCH".equals(code)) {
@ -69,7 +440,7 @@ public class KeygenLicenseVerifier {
return false; return false;
} catch (Exception e) { } catch (Exception e) {
log.error("Error verifying license: {}", e.getMessage()); log.error("Error verifying standard license: {}", e.getMessage());
return false; return false;
} }
} }
@ -96,7 +467,7 @@ public class KeygenLicenseVerifier {
.build(); .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
log.debug("ValidateLicenseResponse body: {}", response.body()); log.info("ValidateLicenseResponse body: {}", response.body());
JsonNode jsonResponse = objectMapper.readTree(response.body()); JsonNode jsonResponse = objectMapper.readTree(response.body());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
JsonNode metaNode = jsonResponse.path("meta"); JsonNode metaNode = jsonResponse.path("meta");
@ -105,9 +476,9 @@ public class KeygenLicenseVerifier {
String detail = metaNode.path("detail").asText(); String detail = metaNode.path("detail").asText();
String code = metaNode.path("code").asText(); String code = metaNode.path("code").asText();
log.debug("License validity: " + isValid); log.info("License validity: " + isValid);
log.debug("Validation detail: " + detail); log.info("Validation detail: " + detail);
log.debug("Validation code: " + code); log.info("Validation code: " + code);
int users = int users =
jsonResponse jsonResponse
@ -116,7 +487,7 @@ public class KeygenLicenseVerifier {
.path("metadata") .path("metadata")
.path("users") .path("users")
.asInt(0); .asInt(0);
applicationProperties.getEnterpriseEdition().setMaxUsers(users); applicationProperties.getPremium().setMaxUsers(users);
log.info(applicationProperties.toString()); log.info(applicationProperties.toString());
} else { } else {
@ -148,13 +519,8 @@ public class KeygenLicenseVerifier {
.put("fingerprint", machineFingerprint) .put("fingerprint", machineFingerprint)
.put( .put(
"platform", "platform",
System.getProperty( System.getProperty("os.name"))
"os.name")) // Added .put("name", hostname))
// platform
// parameter
.put(
"name",
hostname)) // Added name parameter
.put( .put(
"relationships", "relationships",
new JSONObject() new JSONObject()
@ -176,16 +542,12 @@ public class KeygenLicenseVerifier {
.uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines")) .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines"))
.header("Content-Type", "application/vnd.api+json") .header("Content-Type", "application/vnd.api+json")
.header("Accept", "application/vnd.api+json") .header("Accept", "application/vnd.api+json")
.header( .header("Authorization", "License " + licenseKey)
"Authorization", .POST(HttpRequest.BodyPublishers.ofString(body.toString()))
"License " + licenseKey) // Keep the license key authentication
.POST(
HttpRequest.BodyPublishers.ofString(
body.toString())) // Send the JSON body
.build(); .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
log.debug("activateMachine Response body: " + response.body()); log.info("activateMachine Response body: " + response.body());
if (response.statusCode() == 201) { if (response.statusCode() == 201) {
log.info("Machine activated successfully"); log.info("Machine activated successfully");
return true; return true;

View File

@ -1,6 +1,9 @@
package stirling.software.SPDF.EE; package stirling.software.SPDF.EE;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -15,11 +18,13 @@ import stirling.software.SPDF.utils.GeneralUtils;
@Slf4j @Slf4j
public class LicenseKeyChecker { public class LicenseKeyChecker {
private static final String FILE_PREFIX = "file:";
private final KeygenLicenseVerifier licenseService; private final KeygenLicenseVerifier licenseService;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private boolean enterpriseEnabledResult = false; private boolean premiumEnabledResult = false;
@Autowired @Autowired
public LicenseKeyChecker( public LicenseKeyChecker(
@ -35,27 +40,58 @@ public class LicenseKeyChecker {
} }
private void checkLicense() { private void checkLicense() {
if (!applicationProperties.getEnterpriseEdition().isEnabled()) { if (!applicationProperties.getPremium().isEnabled()) {
enterpriseEnabledResult = false; premiumEnabledResult = false;
} else { } else {
enterpriseEnabledResult = String licenseKey = getLicenseKeyContent(applicationProperties.getPremium().getKey());
licenseService.verifyLicense( if (licenseKey != null) {
applicationProperties.getEnterpriseEdition().getKey()); premiumEnabledResult = licenseService.verifyLicense(licenseKey);
if (enterpriseEnabledResult) { if (premiumEnabledResult) {
log.info("License key is valid."); log.info("License key is valid.");
} else {
log.info("License key is invalid.");
}
} else { } else {
log.info("License key is invalid."); log.error("Failed to obtain license key content.");
premiumEnabledResult = false;
} }
} }
} }
private String getLicenseKeyContent(String keyOrFilePath) {
if (keyOrFilePath == null || keyOrFilePath.trim().isEmpty()) {
log.error("License key is not specified");
return null;
}
// Check if it's a file reference
if (keyOrFilePath.startsWith(FILE_PREFIX)) {
String filePath = keyOrFilePath.substring(FILE_PREFIX.length());
try {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
log.error("License file does not exist: {}", filePath);
return null;
}
log.info("Reading license from file: {}", filePath);
return Files.readString(path);
} catch (IOException e) {
log.error("Failed to read license file: {}", e.getMessage());
return null;
}
}
// It's a direct license key
return keyOrFilePath;
}
public void updateLicenseKey(String newKey) throws IOException { public void updateLicenseKey(String newKey) throws IOException {
applicationProperties.getEnterpriseEdition().setKey(newKey); applicationProperties.getPremium().setKey(newKey);
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
checkLicense(); checkLicense();
} }
public boolean getEnterpriseEnabledResult() { public boolean getEnterpriseEnabledResult() {
return enterpriseEnabledResult; return premiumEnabledResult;
} }
} }

View File

@ -182,7 +182,7 @@ public class AppConfig {
@Bean(name = "analyticsEnabled") @Bean(name = "analyticsEnabled")
@Scope("request") @Scope("request")
public boolean analyticsEnabled() { public boolean analyticsEnabled() {
if (applicationProperties.getEnterpriseEdition().isEnabled()) return true; if (applicationProperties.getPremium().isEnabled()) return true;
return applicationProperties.getSystem().isAnalyticsEnabled(); return applicationProperties.getSystem().isAnalyticsEnabled();
} }

View File

@ -9,6 +9,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.List;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -56,6 +57,8 @@ public class ConfigInitializer {
YamlHelper settingsTemplateFile = new YamlHelper(tempTemplatePath); YamlHelper settingsTemplateFile = new YamlHelper(tempTemplatePath);
YamlHelper settingsFile = new YamlHelper(settingTempPath); YamlHelper settingsFile = new YamlHelper(settingTempPath);
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
boolean changesMade = boolean changesMade =
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile); settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
if (changesMade) { if (changesMade) {
@ -76,4 +79,46 @@ public class ConfigInitializer {
log.info("Created custom_settings file: {}", customSettingsPath.toString()); log.info("Created custom_settings file: {}", customSettingsPath.toString());
} }
} }
// TODO: Remove post migration
private void migrateEnterpriseEditionToPremium(YamlHelper yaml, YamlHelper template) {
if (yaml.getValueByExactKeyPath("enterpriseEdition", "enabled") != null) {
template.updateValue(
List.of("premium", "enabled"),
yaml.getValueByExactKeyPath("enterpriseEdition", "enabled"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "key") != null) {
template.updateValue(
List.of("premium", "key"),
yaml.getValueByExactKeyPath("enterpriseEdition", "key"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) {
template.updateValue(
List.of("premium", "proFeatures", "SSOAutoLogin"),
yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")
!= null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"),
yaml.getValueByExactKeyPath(
"enterpriseEdition", "CustomMetadata", "autoUpdateMetadata"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "author"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "creator"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator"));
}
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")
!= null) {
template.updateValue(
List.of("premium", "proFeatures", "CustomMetadata", "producer"),
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer"));
}
}
} }

View File

@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -22,10 +23,14 @@ public class EndpointConfiguration {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>(); private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>(); private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private final boolean runningEE;
@Autowired @Autowired
public EndpointConfiguration(ApplicationProperties applicationProperties) { public EndpointConfiguration(
ApplicationProperties applicationProperties,
@Qualifier("runningEE") boolean runningEE) {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
this.runningEE = runningEE;
init(); init();
processEnvironmentConfigs(); processEnvironmentConfigs();
} }
@ -281,6 +286,13 @@ public class EndpointConfiguration {
} }
} }
} }
if (!runningEE) {
disableGroup("enterprise");
}
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
disableEndpoint("url-to-pdf");
}
} }
public Set<String> getEndpointsForGroup(String group) { public Set<String> getEndpointsForGroup(String group) {

View File

@ -36,7 +36,6 @@ public class EndpointInspector implements ApplicationListener<ContextRefreshedEv
if (!endpointsDiscovered) { if (!endpointsDiscovered) {
discoverEndpoints(); discoverEndpoints();
endpointsDiscovered = true; endpointsDiscovered = true;
logger.info("Discovered {} valid GET endpoints", validGetEndpoints.size());
} }
} }

View File

@ -6,7 +6,10 @@ import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Component @Component
@Slf4j
public class EndpointInterceptor implements HandlerInterceptor { public class EndpointInterceptor implements HandlerInterceptor {
private final EndpointConfiguration endpointConfiguration; private final EndpointConfiguration endpointConfiguration;

View File

@ -0,0 +1,38 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class EnterpriseEndpointFilter extends OncePerRequestFilter {
private final boolean runningEE;
public EnterpriseEndpointFilter(@Qualifier("runningEE") boolean runningEE) {
this.runningEE = runningEE;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!runningEE && isPrometheusEndpointRequest(request)) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
filterChain.doFilter(request, response);
}
private boolean isPrometheusEndpointRequest(HttpServletRequest request) {
return request.getRequestURI().contains("/actuator/");
}
}

View File

@ -125,8 +125,7 @@ public class MergeController {
public ResponseEntity<byte[]> mergePdfs(@ModelAttribute MergePdfsRequest form) public ResponseEntity<byte[]> mergePdfs(@ModelAttribute MergePdfsRequest form)
throws IOException { throws IOException {
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
ByteArrayOutputStream docOutputstream = File mergedTempFile = null;
new ByteArrayOutputStream(); // Stream for the merged document
PDDocument mergedDocument = null; PDDocument mergedDocument = null;
boolean removeCertSign = form.isRemoveCertSign(); boolean removeCertSign = form.isRemoveCertSign();
@ -139,21 +138,24 @@ public class MergeController {
form.getSortType())); // Sort files based on the given sort type form.getSortType())); // Sort files based on the given sort type
PDFMergerUtility mergerUtility = new PDFMergerUtility(); PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
for (MultipartFile multipartFile : files) { for (MultipartFile multipartFile : files) {
totalSize += multipartFile.getSize();
File tempFile = File tempFile =
GeneralUtils.convertMultipartFileToFile( GeneralUtils.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion filesToDelete.add(tempFile); // Add temp file to the list for later deletion
mergerUtility.addSource(tempFile); // Add source file to the merger utility mergerUtility.addSource(tempFile); // Add source file to the merger utility
} }
mergerUtility.setDestinationStream(
docOutputstream); // Set the output stream for the merged document mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.mergeDocuments(null); // Merge the documents mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
byte[] mergedPdfBytes = docOutputstream.toByteArray(); // Get merged document bytes mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(totalSize)); // Merge the documents
// Load the merged PDF document // Load the merged PDF document
mergedDocument = pdfDocumentFactory.load(mergedPdfBytes); mergedDocument = pdfDocumentFactory.load(mergedTempFile);
// Remove signatures if removeCertSign is true // Remove signatures if removeCertSign is true
if (removeCertSign) { if (removeCertSign) {
@ -180,21 +182,23 @@ public class MergeController {
String mergedFileName = String mergedFileName =
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "") files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_merged_unsigned.pdf"; + "_merged_unsigned.pdf";
return WebResponseUtils.bytesToWebResponse( return WebResponseUtils.boasToWebResponse(
baos.toByteArray(), mergedFileName); // Return the modified PDF baos, mergedFileName); // Return the modified PDF
} catch (Exception ex) { } catch (Exception ex) {
log.error("Error in merge pdf process", ex); log.error("Error in merge pdf process", ex);
throw ex; throw ex;
} finally { } finally {
if (mergedDocument != null) {
mergedDocument.close(); // Close the merged document
}
for (File file : filesToDelete) { for (File file : filesToDelete) {
if (file != null) { if (file != null) {
Files.deleteIfExists(file.toPath()); // Delete temporary files Files.deleteIfExists(file.toPath()); // Delete temporary files
} }
} }
docOutputstream.close(); if (mergedTempFile != null) {
if (mergedDocument != null) { Files.deleteIfExists(mergedTempFile.toPath());
mergedDocument.close(); // Close the merged document
} }
} }
} }

View File

@ -40,9 +40,6 @@ public class SplitPdfBySizeController {
@Autowired @Autowired
public SplitPdfBySizeController(CustomPDFDocumentFactory pdfDocumentFactory) { public SplitPdfBySizeController(CustomPDFDocumentFactory pdfDocumentFactory) {
this.pdfDocumentFactory = pdfDocumentFactory; this.pdfDocumentFactory = pdfDocumentFactory;
log.info(
"SplitPdfBySizeController initialized with pdfDocumentFactory: {}",
pdfDocumentFactory.getClass().getSimpleName());
} }
@PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data") @PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@ -57,53 +54,49 @@ public class SplitPdfBySizeController {
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request) public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request)
throws Exception { throws Exception {
log.info("Starting PDF split process with request: {}", request); log.debug("Starting PDF split process with request: {}", request);
MultipartFile file = request.getFileInput(); MultipartFile file = request.getFileInput();
log.info(
"File received: name={}, size={} bytes",
file.getOriginalFilename(),
file.getSize());
Path zipFile = Files.createTempFile("split_documents", ".zip"); Path zipFile = Files.createTempFile("split_documents", ".zip");
log.info("Created temporary zip file: {}", zipFile); log.debug("Created temporary zip file: {}", zipFile);
String filename = String filename =
Filenames.toSimpleFileName(file.getOriginalFilename()) Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", ""); .replaceFirst("[.][^.]+$", "");
log.info("Base filename for output: {}", filename); log.debug("Base filename for output: {}", filename);
byte[] data = null; byte[] data = null;
try { try {
log.info("Reading input file bytes"); log.debug("Reading input file bytes");
byte[] pdfBytes = file.getBytes(); byte[] pdfBytes = file.getBytes();
log.info("Successfully read {} bytes from input file", pdfBytes.length); log.debug("Successfully read {} bytes from input file", pdfBytes.length);
log.info("Creating ZIP output stream"); log.debug("Creating ZIP output stream");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) { try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
log.info("Loading PDF document"); log.debug("Loading PDF document");
try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) { try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) {
log.info( log.debug(
"Successfully loaded PDF with {} pages", "Successfully loaded PDF with {} pages",
sourceDocument.getNumberOfPages()); sourceDocument.getNumberOfPages());
int type = request.getSplitType(); int type = request.getSplitType();
String value = request.getSplitValue(); String value = request.getSplitValue();
log.info("Split type: {}, Split value: {}", type, value); log.debug("Split type: {}, Split value: {}", type, value);
if (type == 0) { if (type == 0) {
log.info("Processing split by size"); log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value); long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.info("Max bytes per document: {}", maxBytes); log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename); handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) { } else if (type == 1) {
log.info("Processing split by page count"); log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value); int pageCount = Integer.parseInt(value);
log.info("Pages per document: {}", pageCount); log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename); handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} else if (type == 2) { } else if (type == 2) {
log.info("Processing split by document count"); log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value); int documentCount = Integer.parseInt(value);
log.info("Total number of documents: {}", documentCount); log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename); handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else { } else {
log.error("Invalid split type: {}", type); log.error("Invalid split type: {}", type);
@ -111,7 +104,7 @@ public class SplitPdfBySizeController {
"Invalid argument for split type: " + type); "Invalid argument for split type: " + type);
} }
log.info("PDF splitting completed successfully"); log.debug("PDF splitting completed successfully");
} catch (Exception e) { } catch (Exception e) {
log.error("Error loading or processing PDF document", e); log.error("Error loading or processing PDF document", e);
throw e; throw e;
@ -126,23 +119,23 @@ public class SplitPdfBySizeController {
throw e; // Re-throw to ensure proper error response throw e; // Re-throw to ensure proper error response
} finally { } finally {
try { try {
log.info("Reading ZIP file data"); log.debug("Reading ZIP file data");
data = Files.readAllBytes(zipFile); data = Files.readAllBytes(zipFile);
log.info("Successfully read {} bytes from ZIP file", data.length); log.debug("Successfully read {} bytes from ZIP file", data.length);
} catch (IOException e) { } catch (IOException e) {
log.error("Error reading ZIP file data", e); log.error("Error reading ZIP file data", e);
} }
try { try {
log.info("Deleting temporary ZIP file"); log.debug("Deleting temporary ZIP file");
boolean deleted = Files.deleteIfExists(zipFile); boolean deleted = Files.deleteIfExists(zipFile);
log.info("Temporary ZIP file deleted: {}", deleted); log.debug("Temporary ZIP file deleted: {}", deleted);
} catch (IOException e) { } catch (IOException e) {
log.error("Error deleting temporary ZIP file", e); log.error("Error deleting temporary ZIP file", e);
} }
} }
log.info("Returning response with {} bytes of data", data != null ? data.length : 0); log.debug("Returning response with {} bytes of data", data != null ? data.length : 0);
return WebResponseUtils.bytesToWebResponse( return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM); data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} }
@ -150,7 +143,7 @@ public class SplitPdfBySizeController {
private void handleSplitBySize( private void handleSplitBySize(
PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename) PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename)
throws IOException { throws IOException {
log.info("Starting handleSplitBySize with maxBytes={}", maxBytes); log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
PDDocument currentDoc = PDDocument currentDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
@ -163,7 +156,7 @@ public class SplitPdfBySizeController {
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage page = sourceDocument.getPage(pageIndex); PDPage page = sourceDocument.getPage(pageIndex);
log.info("Processing page {} of {}", pageIndex + 1, totalPages); log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
// Add the page to current document // Add the page to current document
PDPage newPage = new PDPage(page.getCOSObject()); PDPage newPage = new PDPage(page.getCOSObject());
@ -177,21 +170,21 @@ public class SplitPdfBySizeController {
|| (pageAdded >= 20); // Always check after 20 pages || (pageAdded >= 20); // Always check after 20 pages
if (shouldCheckSize) { if (shouldCheckSize) {
log.info("Performing size check after {} pages", pageAdded); log.debug("Performing size check after {} pages", pageAdded);
ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream(); ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream();
currentDoc.save(checkSizeStream); currentDoc.save(checkSizeStream);
long actualSize = checkSizeStream.size(); long actualSize = checkSizeStream.size();
log.info("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes); log.debug("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes);
if (actualSize > maxBytes) { if (actualSize > maxBytes) {
// We exceeded the limit - remove the last page and save // We exceeded the limit - remove the last page and save
if (currentDoc.getNumberOfPages() > 1) { if (currentDoc.getNumberOfPages() > 1) {
currentDoc.removePage(currentDoc.getNumberOfPages() - 1); currentDoc.removePage(currentDoc.getNumberOfPages() - 1);
pageIndex--; // Process this page again in the next document pageIndex--; // Process this page again in the next document
log.info("Size limit exceeded - removed last page"); log.debug("Size limit exceeded - removed last page");
} }
log.info( log.debug(
"Saving document with {} pages as part {}", "Saving document with {} pages as part {}",
currentDoc.getNumberOfPages(), currentDoc.getNumberOfPages(),
fileIndex); fileIndex);
@ -206,7 +199,7 @@ public class SplitPdfBySizeController {
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1); int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
if (pagesToLookAhead > 0) { if (pagesToLookAhead > 0) {
log.info( log.debug(
"Testing {} upcoming pages for potential addition", "Testing {} upcoming pages for potential addition",
pagesToLookAhead); pagesToLookAhead);
@ -231,12 +224,12 @@ public class SplitPdfBySizeController {
if (testSize <= maxBytes) { if (testSize <= maxBytes) {
extraPagesAdded++; extraPagesAdded++;
log.info( log.debug(
"Test: Can add page {} (size would be {})", "Test: Can add page {} (size would be {})",
testPageIndex + 1, testPageIndex + 1,
testSize); testSize);
} else { } else {
log.info( log.debug(
"Test: Cannot add page {} (size would be {})", "Test: Cannot add page {} (size would be {})",
testPageIndex + 1, testPageIndex + 1,
testSize); testSize);
@ -248,7 +241,7 @@ public class SplitPdfBySizeController {
// Add the pages we verified would fit // Add the pages we verified would fit
if (extraPagesAdded > 0) { if (extraPagesAdded > 0) {
log.info("Adding {} verified pages ahead", extraPagesAdded); log.debug("Adding {} verified pages ahead", extraPagesAdded);
for (int i = 0; i < extraPagesAdded; i++) { for (int i = 0; i < extraPagesAdded; i++) {
int extraPageIndex = pageIndex + 1 + i; int extraPageIndex = pageIndex + 1 + i;
PDPage extraPage = sourceDocument.getPage(extraPageIndex); PDPage extraPage = sourceDocument.getPage(extraPageIndex);
@ -265,26 +258,26 @@ public class SplitPdfBySizeController {
// Save final document if it has any pages // Save final document if it has any pages
if (currentDoc.getNumberOfPages() > 0) { if (currentDoc.getNumberOfPages() > 0) {
log.info( log.debug(
"Saving final document with {} pages as part {}", "Saving final document with {} pages as part {}",
currentDoc.getNumberOfPages(), currentDoc.getNumberOfPages(),
fileIndex); fileIndex);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
} }
log.info("Completed handleSplitBySize with {} document parts created", fileIndex - 1); log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
} }
private void handleSplitByPageCount( private void handleSplitByPageCount(
PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename) PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename)
throws IOException { throws IOException {
log.info("Starting handleSplitByPageCount with pageCount={}", pageCount); log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
int currentPageCount = 0; int currentPageCount = 0;
log.info("Creating initial output document"); log.debug("Creating initial output document");
PDDocument currentDoc = null; PDDocument currentDoc = null;
try { try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.info("Successfully created initial output document"); log.debug("Successfully created initial output document");
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating initial output document", e); log.error("Error creating initial output document", e);
throw new IOException("Failed to create initial output document", e); throw new IOException("Failed to create initial output document", e);
@ -293,49 +286,49 @@ public class SplitPdfBySizeController {
int fileIndex = 1; int fileIndex = 1;
int pageIndex = 0; int pageIndex = 0;
int totalPages = sourceDocument.getNumberOfPages(); int totalPages = sourceDocument.getNumberOfPages();
log.info("Processing {} pages", totalPages); log.debug("Processing {} pages", totalPages);
try { try {
for (PDPage page : sourceDocument.getPages()) { for (PDPage page : sourceDocument.getPages()) {
pageIndex++; pageIndex++;
log.info("Processing page {} of {}", pageIndex, totalPages); log.debug("Processing page {} of {}", pageIndex, totalPages);
try { try {
log.info("Adding page {} to current document", pageIndex); log.debug("Adding page {} to current document", pageIndex);
currentDoc.addPage(page); currentDoc.addPage(page);
log.info("Successfully added page {} to current document", pageIndex); log.debug("Successfully added page {} to current document", pageIndex);
} catch (Exception e) { } catch (Exception e) {
log.error("Error adding page {} to current document", pageIndex, e); log.error("Error adding page {} to current document", pageIndex, e);
throw new IOException("Failed to add page to document", e); throw new IOException("Failed to add page to document", e);
} }
currentPageCount++; currentPageCount++;
log.info("Current page count: {}/{}", currentPageCount, pageCount); log.debug("Current page count: {}/{}", currentPageCount, pageCount);
if (currentPageCount == pageCount) { if (currentPageCount == pageCount) {
log.info( log.debug(
"Reached target page count ({}), saving current document as part {}", "Reached target page count ({}), saving current document as part {}",
pageCount, pageCount,
fileIndex); fileIndex);
try { try {
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.info("Successfully saved document part {}", fileIndex - 1); log.debug("Successfully saved document part {}", fileIndex - 1);
} catch (Exception e) { } catch (Exception e) {
log.error("Error saving document part {}", fileIndex - 1, e); log.error("Error saving document part {}", fileIndex - 1, e);
throw e; throw e;
} }
try { try {
log.info("Creating new document for next part"); log.debug("Creating new document for next part");
currentDoc = new PDDocument(); currentDoc = new PDDocument();
log.info("Successfully created new document"); log.debug("Successfully created new document");
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating new document for next part", e); log.error("Error creating new document for next part", e);
throw new IOException("Failed to create new document", e); throw new IOException("Failed to create new document", e);
} }
currentPageCount = 0; currentPageCount = 0;
log.info("Reset current page count to 0"); log.debug("Reset current page count to 0");
} }
} }
} catch (Exception e) { } catch (Exception e) {
@ -346,34 +339,34 @@ public class SplitPdfBySizeController {
// Add the last document if it contains any pages // Add the last document if it contains any pages
try { try {
if (currentDoc.getPages().getCount() != 0) { if (currentDoc.getPages().getCount() != 0) {
log.info( log.debug(
"Saving final document with {} pages as part {}", "Saving final document with {} pages as part {}",
currentDoc.getPages().getCount(), currentDoc.getPages().getCount(),
fileIndex); fileIndex);
try { try {
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.info("Successfully saved final document part {}", fileIndex - 1); log.debug("Successfully saved final document part {}", fileIndex - 1);
} catch (Exception e) { } catch (Exception e) {
log.error("Error saving final document part {}", fileIndex - 1, e); log.error("Error saving final document part {}", fileIndex - 1, e);
throw e; throw e;
} }
} else { } else {
log.info("Final document has no pages, skipping"); log.debug("Final document has no pages, skipping");
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error checking or saving final document", e); log.error("Error checking or saving final document", e);
throw new IOException("Failed to process final document", e); throw new IOException("Failed to process final document", e);
} finally { } finally {
try { try {
log.info("Closing final document"); log.debug("Closing final document");
currentDoc.close(); currentDoc.close();
log.info("Successfully closed final document"); log.debug("Successfully closed final document");
} catch (Exception e) { } catch (Exception e) {
log.error("Error closing final document", e); log.error("Error closing final document", e);
} }
} }
log.info("Completed handleSplitByPageCount with {} document parts created", fileIndex - 1); log.debug("Completed handleSplitByPageCount with {} document parts created", fileIndex - 1);
} }
private void handleSplitByDocCount( private void handleSplitByDocCount(
@ -382,40 +375,40 @@ public class SplitPdfBySizeController {
ZipOutputStream zipOut, ZipOutputStream zipOut,
String baseFilename) String baseFilename)
throws IOException { throws IOException {
log.info("Starting handleSplitByDocCount with documentCount={}", documentCount); log.debug("Starting handleSplitByDocCount with documentCount={}", documentCount);
int totalPageCount = sourceDocument.getNumberOfPages(); int totalPageCount = sourceDocument.getNumberOfPages();
log.info("Total pages in source document: {}", totalPageCount); log.debug("Total pages in source document: {}", totalPageCount);
int pagesPerDocument = totalPageCount / documentCount; int pagesPerDocument = totalPageCount / documentCount;
int extraPages = totalPageCount % documentCount; int extraPages = totalPageCount % documentCount;
log.info("Pages per document: {}, Extra pages: {}", pagesPerDocument, extraPages); log.debug("Pages per document: {}, Extra pages: {}", pagesPerDocument, extraPages);
int currentPageIndex = 0; int currentPageIndex = 0;
int fileIndex = 1; int fileIndex = 1;
for (int i = 0; i < documentCount; i++) { for (int i = 0; i < documentCount; i++) {
log.info("Creating document {} of {}", i + 1, documentCount); log.debug("Creating document {} of {}", i + 1, documentCount);
PDDocument currentDoc = null; PDDocument currentDoc = null;
try { try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.info("Successfully created document {} of {}", i + 1, documentCount); log.debug("Successfully created document {} of {}", i + 1, documentCount);
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating document {} of {}", i + 1, documentCount, e); log.error("Error creating document {} of {}", i + 1, documentCount, e);
throw new IOException("Failed to create document", e); throw new IOException("Failed to create document", e);
} }
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
log.info("Adding {} pages to document {}", pagesToAdd, i + 1); log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
for (int j = 0; j < pagesToAdd; j++) { for (int j = 0; j < pagesToAdd; j++) {
try { try {
log.info( log.debug(
"Adding page {} (index {}) to document {}", "Adding page {} (index {}) to document {}",
j + 1, j + 1,
currentPageIndex, currentPageIndex,
i + 1); i + 1);
currentDoc.addPage(sourceDocument.getPage(currentPageIndex)); currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
log.info("Successfully added page {} to document {}", j + 1, i + 1); log.debug("Successfully added page {} to document {}", j + 1, i + 1);
currentPageIndex++; currentPageIndex++;
} catch (Exception e) { } catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e); log.error("Error adding page {} to document {}", j + 1, i + 1, e);
@ -424,37 +417,37 @@ public class SplitPdfBySizeController {
} }
try { try {
log.info("Saving document {} with {} pages", i + 1, pagesToAdd); log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.info("Successfully saved document {}", i + 1); log.debug("Successfully saved document {}", i + 1);
} catch (Exception e) { } catch (Exception e) {
log.error("Error saving document {}", i + 1, e); log.error("Error saving document {}", i + 1, e);
throw e; throw e;
} }
} }
log.info("Completed handleSplitByDocCount with {} documents created", documentCount); log.debug("Completed handleSplitByDocCount with {} documents created", documentCount);
} }
private void saveDocumentToZip( private void saveDocumentToZip(
PDDocument document, ZipOutputStream zipOut, String baseFilename, int index) PDDocument document, ZipOutputStream zipOut, String baseFilename, int index)
throws IOException { throws IOException {
log.info("Starting saveDocumentToZip for document part {}", index); log.debug("Starting saveDocumentToZip for document part {}", index);
ByteArrayOutputStream outStream = new ByteArrayOutputStream(); ByteArrayOutputStream outStream = new ByteArrayOutputStream();
try { try {
log.info("Saving document part {} to byte array", index); log.debug("Saving document part {} to byte array", index);
document.save(outStream); document.save(outStream);
log.info("Successfully saved document part {} ({} bytes)", index, outStream.size()); log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
} catch (Exception e) { } catch (Exception e) {
log.error("Error saving document part {} to byte array", index, e); log.error("Error saving document part {} to byte array", index, e);
throw new IOException("Failed to save document to byte array", e); throw new IOException("Failed to save document to byte array", e);
} }
try { try {
log.info("Closing document part {}", index); log.debug("Closing document part {}", index);
document.close(); document.close();
log.info("Successfully closed document part {}", index); log.debug("Successfully closed document part {}", index);
} catch (Exception e) { } catch (Exception e) {
log.error("Error closing document part {}", index, e); log.error("Error closing document part {}", index, e);
// Continue despite close error // Continue despite close error
@ -463,17 +456,17 @@ public class SplitPdfBySizeController {
try { try {
// Create a new zip entry // Create a new zip entry
String entryName = baseFilename + "_" + index + ".pdf"; String entryName = baseFilename + "_" + index + ".pdf";
log.info("Creating ZIP entry: {}", entryName); log.debug("Creating ZIP entry: {}", entryName);
ZipEntry zipEntry = new ZipEntry(entryName); ZipEntry zipEntry = new ZipEntry(entryName);
zipOut.putNextEntry(zipEntry); zipOut.putNextEntry(zipEntry);
byte[] bytes = outStream.toByteArray(); byte[] bytes = outStream.toByteArray();
log.info("Writing {} bytes to ZIP entry", bytes.length); log.debug("Writing {} bytes to ZIP entry", bytes.length);
zipOut.write(bytes); zipOut.write(bytes);
log.info("Closing ZIP entry"); log.debug("Closing ZIP entry");
zipOut.closeEntry(); zipOut.closeEntry();
log.info("Successfully added document part {} to ZIP", index); log.debug("Successfully added document part {} to ZIP", index);
} catch (Exception e) { } catch (Exception e) {
log.error("Error adding document part {} to ZIP", index, e); log.error("Error adding document part {} to ZIP", index, e);
throw new IOException("Failed to add document to ZIP file", e); throw new IOException("Failed to add document to ZIP file", e);

View File

@ -32,6 +32,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.UserService; import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.AuthenticationType; import stirling.software.SPDF.model.AuthenticationType;
import stirling.software.SPDF.model.Role; import stirling.software.SPDF.model.Role;
import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.User;
@ -47,10 +48,15 @@ public class UserController {
private static final String LOGIN_MESSAGETYPE_CREDSUPDATED = "/login?messageType=credsUpdated"; private static final String LOGIN_MESSAGETYPE_CREDSUPDATED = "/login?messageType=credsUpdated";
private final UserService userService; private final UserService userService;
private final SessionPersistentRegistry sessionRegistry; private final SessionPersistentRegistry sessionRegistry;
private final ApplicationProperties applicationProperties;
public UserController(UserService userService, SessionPersistentRegistry sessionRegistry) { public UserController(
UserService userService,
SessionPersistentRegistry sessionRegistry,
ApplicationProperties applicationProperties) {
this.userService = userService; this.userService = userService;
this.sessionRegistry = sessionRegistry; this.sessionRegistry = sessionRegistry;
this.applicationProperties = applicationProperties;
} }
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@ -194,39 +200,44 @@ public class UserController {
boolean forceChange) boolean forceChange)
throws IllegalArgumentException, SQLException, UnsupportedProviderException { throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(username)) { if (!userService.isUsernameValid(username)) {
return new RedirectView("/addUsers?messageType=invalidUsername", true); return new RedirectView("/adminSettings?messageType=invalidUsername", true);
}
if (applicationProperties.getPremium().isEnabled()
&& applicationProperties.getPremium().getMaxUsers()
<= userService.getTotalUsersCount()) {
return new RedirectView("/adminSettings?messageType=maxUsersReached", true);
} }
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username); Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) { if (userOpt.isPresent()) {
User user = userOpt.get(); User user = userOpt.get();
if (user.getUsername().equalsIgnoreCase(username)) { if (user.getUsername().equalsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=usernameExists", true); return new RedirectView("/adminSettings?messageType=usernameExists", true);
} }
} }
if (userService.usernameExistsIgnoreCase(username)) { if (userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=usernameExists", true); return new RedirectView("/adminSettings?messageType=usernameExists", true);
} }
try { try {
// Validate the role // Validate the role
Role roleEnum = Role.fromString(role); Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) { if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request // If the role is INTERNAL_API_USER, reject the request
return new RedirectView("/addUsers?messageType=invalidRole", true); return new RedirectView("/adminSettings?messageType=invalidRole", true);
} }
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
// If the role ID is not valid, redirect with an error message // If the role ID is not valid, redirect with an error message
return new RedirectView("/addUsers?messageType=invalidRole", true); return new RedirectView("/adminSettings?messageType=invalidRole", true);
} }
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, role); userService.saveUser(username, AuthenticationType.SSO, role);
} else { } else {
if (password.isBlank()) { if (password.isBlank()) {
return new RedirectView("/addUsers?messageType=invalidPassword", true); return new RedirectView("/adminSettings?messageType=invalidPassword", true);
} }
userService.saveUser(username, password, role, forceChange); userService.saveUser(username, password, role, forceChange);
} }
return new RedirectView( return new RedirectView(
"/addUsers", // Redirect to account page after adding the user "/adminSettings", // Redirect to account page after adding the user
true); true);
} }
@ -239,32 +250,32 @@ public class UserController {
throws SQLException, UnsupportedProviderException { throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username); Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (!userOpt.isPresent()) { if (!userOpt.isPresent()) {
return new RedirectView("/addUsers?messageType=userNotFound", true); return new RedirectView("/adminSettings?messageType=userNotFound", true);
} }
if (!userService.usernameExistsIgnoreCase(username)) { if (!userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=userNotFound", true); return new RedirectView("/adminSettings?messageType=userNotFound", true);
} }
// Get the currently authenticated username // Get the currently authenticated username
String currentUsername = authentication.getName(); String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username // Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) { if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=downgradeCurrentUser", true); return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true);
} }
try { try {
// Validate the role // Validate the role
Role roleEnum = Role.fromString(role); Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) { if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request // If the role is INTERNAL_API_USER, reject the request
return new RedirectView("/addUsers?messageType=invalidRole", true); return new RedirectView("/adminSettings?messageType=invalidRole", true);
} }
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
// If the role ID is not valid, redirect with an error message // If the role ID is not valid, redirect with an error message
return new RedirectView("/addUsers?messageType=invalidRole", true); return new RedirectView("/adminSettings?messageType=invalidRole", true);
} }
User user = userOpt.get(); User user = userOpt.get();
userService.changeRole(user, role); userService.changeRole(user, role);
return new RedirectView( return new RedirectView(
"/addUsers", // Redirect to account page after adding the user "/adminSettings", // Redirect to account page after adding the user
true); true);
} }
@ -277,16 +288,16 @@ public class UserController {
throws SQLException, UnsupportedProviderException { throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username); Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) { if (userOpt.isEmpty()) {
return new RedirectView("/addUsers?messageType=userNotFound", true); return new RedirectView("/adminSettings?messageType=userNotFound", true);
} }
if (!userService.usernameExistsIgnoreCase(username)) { if (!userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=userNotFound", true); return new RedirectView("/adminSettings?messageType=userNotFound", true);
} }
// Get the currently authenticated username // Get the currently authenticated username
String currentUsername = authentication.getName(); String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username // Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) { if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=disabledCurrentUser", true); return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true);
} }
User user = userOpt.get(); User user = userOpt.get();
userService.changeUserEnabled(user, enabled); userService.changeUserEnabled(user, enabled);
@ -314,7 +325,7 @@ public class UserController {
} }
} }
return new RedirectView( return new RedirectView(
"/addUsers", // Redirect to account page after adding the user "/adminSettings", // Redirect to account page after adding the user
true); true);
} }
@ -323,13 +334,13 @@ public class UserController {
public RedirectView deleteUser( public RedirectView deleteUser(
@PathVariable("username") String username, Authentication authentication) { @PathVariable("username") String username, Authentication authentication) {
if (!userService.usernameExistsIgnoreCase(username)) { if (!userService.usernameExistsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=deleteUsernameExists", true); return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true);
} }
// Get the currently authenticated username // Get the currently authenticated username
String currentUsername = authentication.getName(); String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username // Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) { if (currentUsername.equalsIgnoreCase(username)) {
return new RedirectView("/addUsers?messageType=deleteCurrentUser", true); return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true);
} }
// Invalidate all sessions before deleting the user // Invalidate all sessions before deleting the user
List<SessionInformation> sessionsInformations = List<SessionInformation> sessionsInformations =
@ -339,7 +350,7 @@ public class UserController {
sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId()); sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId());
} }
userService.deleteUser(username); userService.deleteUser(username);
return new RedirectView("/addUsers", true); return new RedirectView("/adminSettings", true);
} }
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")

View File

@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.RuntimePathConfig; import stirling.software.SPDF.config.RuntimePathConfig;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest; import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.SPDF.service.CustomPDFDocumentFactory; import stirling.software.SPDF.service.CustomPDFDocumentFactory;
import stirling.software.SPDF.utils.GeneralUtils; import stirling.software.SPDF.utils.GeneralUtils;
@ -35,12 +36,16 @@ public class ConvertWebsiteToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
@Autowired @Autowired
public ConvertWebsiteToPDF( public ConvertWebsiteToPDF(
CustomPDFDocumentFactory pdfDocumentFactory, RuntimePathConfig runtimePathConfig) { CustomPDFDocumentFactory pdfDocumentFactory,
RuntimePathConfig runtimePathConfig,
ApplicationProperties applicationProperties) {
this.pdfDocumentFactory = pdfDocumentFactory; this.pdfDocumentFactory = pdfDocumentFactory;
this.runtimePathConfig = runtimePathConfig; this.runtimePathConfig = runtimePathConfig;
this.applicationProperties = applicationProperties;
} }
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf") @PostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@ -53,6 +58,9 @@ public class ConvertWebsiteToPDF {
throws IOException, InterruptedException { throws IOException, InterruptedException {
String URL = request.getUrlInput(); String URL = request.getUrlInput();
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
throw new IllegalArgumentException("This endpoint has been disabled by the admin.");
}
// Validate the URL format // Validate the URL format
if (!URL.matches("^https?://.*") || !GeneralUtils.isValidURL(URL)) { if (!URL.matches("^https?://.*") || !GeneralUtils.isValidURL(URL)) {
throw new IllegalArgumentException("Invalid URL format provided."); throw new IllegalArgumentException("Invalid URL format provided.");

View File

@ -118,11 +118,11 @@ public class AccountWebController {
if (securityProps.isSaml2Active() if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality() && applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getEnterpriseEdition().isEnabled()) { && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider(); String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
if (applicationProperties.getEnterpriseEdition().isSsoAutoLogin()) { if (applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
return "redirect:" + request.getRequestURL() + saml2AuthenticationPath; return "redirect:" + request.getRequestURL() + saml2AuthenticationPath;
} else { } else {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
@ -195,7 +195,13 @@ public class AccountWebController {
} }
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/addUsers") @GetMapping("/usage")
public String showUsage() {
return "usage";
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/adminSettings")
public String showAddUserForm( public String showAddUserForm(
HttpServletRequest request, Model model, Authentication authentication) { HttpServletRequest request, Model model, Authentication authentication) {
List<User> allUsers = userRepository.findAll(); List<User> allUsers = userRepository.findAll();
@ -318,7 +324,9 @@ public class AccountWebController {
model.addAttribute("totalUsers", allUsers.size()); model.addAttribute("totalUsers", allUsers.size());
model.addAttribute("activeUsers", activeUsers); model.addAttribute("activeUsers", activeUsers);
model.addAttribute("disabledUsers", disabledUsers); model.addAttribute("disabledUsers", disabledUsers);
return "addUsers";
model.addAttribute("maxEnterpriseUsers", applicationProperties.getPremium().getMaxUsers());
return "adminSettings";
} }
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")

View File

@ -22,6 +22,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener; import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties;
@ -32,15 +33,17 @@ import stirling.software.SPDF.model.ApplicationProperties;
public class MetricsController { public class MetricsController {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry; private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private boolean metricsEnabled; private boolean metricsEnabled;
public MetricsController( public MetricsController(
ApplicationProperties applicationProperties, MeterRegistry meterRegistry) { ApplicationProperties applicationProperties,
MeterRegistry meterRegistry,
EndpointInspector endpointInspector) {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
this.meterRegistry = meterRegistry; this.meterRegistry = meterRegistry;
this.endpointInspector = endpointInspector;
} }
@PostConstruct @PostConstruct
@ -208,25 +211,43 @@ public class MetricsController {
} }
private double getRequestCount(String method, Optional<String> endpoint) { private double getRequestCount(String method, Optional<String> endpoint) {
log.info( return meterRegistry.find("http.requests").tag("method", method).counters().stream()
"Getting request count for method: {}, endpoint: {}", .filter(
method, counter -> {
endpoint.orElse("all")); String uri = counter.getId().getTag("uri");
double count =
meterRegistry.find("http.requests").tag("method", method).counters().stream() // Apply filtering logic - Skip if uri is null
.filter( if (uri == null) {
counter -> return false;
!endpoint.isPresent() }
|| endpoint.get()
.equals(counter.getId().getTag("uri"))) // For POST requests, only include if they start with /api/v1
.mapToDouble(Counter::count) if ("POST".equals(method) && !uri.contains("api/v1")) {
.sum(); return false;
log.info("Request count: {}", count); }
return count;
if (uri.contains(".txt")) {
return false;
}
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
log.debug("Skipping invalid GET endpoint: {}", uri);
return false;
}
// Filter for specific endpoint if provided
return !endpoint.isPresent() || endpoint.get().equals(uri);
})
.mapToDouble(Counter::count)
.sum();
} }
private List<EndpointCount> getEndpointCounts(String method) { private List<EndpointCount> getEndpointCounts(String method) {
log.info("Getting endpoint counts for method: {}", method);
Map<String, Double> counts = new HashMap<>(); Map<String, Double> counts = new HashMap<>();
meterRegistry meterRegistry
.find("http.requests") .find("http.requests")
@ -235,28 +256,72 @@ public class MetricsController {
.forEach( .forEach(
counter -> { counter -> {
String uri = counter.getId().getTag("uri"); String uri = counter.getId().getTag("uri");
// Skip if uri is null
if (uri == null) {
return;
}
// For POST requests, only include if they start with /api/v1
if ("POST".equals(method) && !uri.contains("api/v1")) {
return;
}
if (uri.contains(".txt")) {
return;
}
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
log.debug("Skipping invalid GET endpoint: {}", uri);
return;
}
counts.merge(uri, counter.count(), Double::sum); counts.merge(uri, counter.count(), Double::sum);
}); });
List<EndpointCount> result =
counts.entrySet().stream() return counts.entrySet().stream()
.map(entry -> new EndpointCount(entry.getKey(), entry.getValue())) .map(entry -> new EndpointCount(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(EndpointCount::getCount).reversed()) .sorted(Comparator.comparing(EndpointCount::getCount).reversed())
.collect(Collectors.toList()); .collect(Collectors.toList());
log.info("Found {} endpoints with counts", result.size());
return result;
} }
private double getUniqueUserCount(String method, Optional<String> endpoint) { private double getUniqueUserCount(String method, Optional<String> endpoint) {
log.info(
"Getting unique user count for method: {}, endpoint: {}",
method,
endpoint.orElse("all"));
Set<String> uniqueUsers = new HashSet<>(); Set<String> uniqueUsers = new HashSet<>();
meterRegistry.find("http.requests").tag("method", method).counters().stream() meterRegistry.find("http.requests").tag("method", method).counters().stream()
.filter( .filter(
counter -> counter -> {
!endpoint.isPresent() String uri = counter.getId().getTag("uri");
|| endpoint.get().equals(counter.getId().getTag("uri")))
// Skip if uri is null
if (uri == null) {
return false;
}
// For POST requests, only include if they start with /api/v1
if ("POST".equals(method) && !uri.contains("api/v1")) {
return false;
}
if (uri.contains(".txt")) {
return false;
}
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
log.debug("Skipping invalid GET endpoint: {}", uri);
return false;
}
return !endpoint.isPresent() || endpoint.get().equals(uri);
})
.forEach( .forEach(
counter -> { counter -> {
String session = counter.getId().getTag("session"); String session = counter.getId().getTag("session");
@ -264,12 +329,10 @@ public class MetricsController {
uniqueUsers.add(session); uniqueUsers.add(session);
} }
}); });
log.info("Unique user count: {}", uniqueUsers.size());
return uniqueUsers.size(); return uniqueUsers.size();
} }
private List<EndpointCount> getUniqueUserCounts(String method) { private List<EndpointCount> getUniqueUserCounts(String method) {
log.info("Getting unique user counts for method: {}", method);
Map<String, Set<String>> uniqueUsers = new HashMap<>(); Map<String, Set<String>> uniqueUsers = new HashMap<>();
meterRegistry meterRegistry
.find("http.requests") .find("http.requests")
@ -283,13 +346,10 @@ public class MetricsController {
uniqueUsers.computeIfAbsent(uri, k -> new HashSet<>()).add(session); uniqueUsers.computeIfAbsent(uri, k -> new HashSet<>()).add(session);
} }
}); });
List<EndpointCount> result = return uniqueUsers.entrySet().stream()
uniqueUsers.entrySet().stream() .map(entry -> new EndpointCount(entry.getKey(), entry.getValue().size()))
.map(entry -> new EndpointCount(entry.getKey(), entry.getValue().size())) .sorted(Comparator.comparing(EndpointCount::getCount).reversed())
.sorted(Comparator.comparing(EndpointCount::getCount).reversed()) .collect(Collectors.toList());
.collect(Collectors.toList());
log.info("Found {} endpoints with unique user counts", result.size());
return result;
} }
@GetMapping("/uptime") @GetMapping("/uptime")

View File

@ -81,6 +81,8 @@ public class ApplicationProperties {
private Endpoints endpoints = new Endpoints(); private Endpoints endpoints = new Endpoints();
private Metrics metrics = new Metrics(); private Metrics metrics = new Metrics();
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated(); private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Premium premium = new Premium();
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition(); private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
private AutoPipeline autoPipeline = new AutoPipeline(); private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor(); private ProcessExecutor processExecutor = new ProcessExecutor();
@ -287,6 +289,7 @@ public class ApplicationProperties {
private Boolean enableAnalytics; private Boolean enableAnalytics;
private Datasource datasource; private Datasource datasource;
private Boolean disableSanitize; private Boolean disableSanitize;
private Boolean enableUrlToPDF;
private CustomPaths customPaths = new CustomPaths(); private CustomPaths customPaths = new CustomPaths();
public boolean isAnalyticsEnabled() { public boolean isAnalyticsEnabled() {
@ -390,6 +393,7 @@ public class ApplicationProperties {
private String appVersion; private String appVersion;
} }
// TODO: Remove post migration
@Data @Data
public static class EnterpriseEdition { public static class EnterpriseEdition {
private boolean enabled; private boolean enabled;
@ -415,6 +419,50 @@ public class ApplicationProperties {
} }
} }
@Data
public static class Premium {
private boolean enabled;
@ToString.Exclude private String key;
private int maxUsers;
private ProFeatures proFeatures = new ProFeatures();
private EnterpriseFeatures enterpriseFeatures = new EnterpriseFeatures();
@Data
public static class ProFeatures {
private boolean ssoAutoLogin;
private CustomMetadata customMetadata = new CustomMetadata();
@Data
public static class CustomMetadata {
private boolean autoUpdateMetadata;
private String author;
private String creator;
private String producer;
public String getCreator() {
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
}
public String getProducer() {
return producer == null || producer.trim().isEmpty()
? "Stirling-PDF"
: producer;
}
}
}
@Data
public static class EnterpriseFeatures {
private PersistentMetrics persistentMetrics = new PersistentMetrics();
@Data
public static class PersistentMetrics {
private boolean enabled;
private int retentionDays;
}
}
}
@Data @Data
public static class ProcessExecutor { public static class ProcessExecutor {
private SessionLimit sessionLimit = new SessionLimit(); private SessionLimit sessionLimit = new SessionLimit();

View File

@ -139,7 +139,7 @@ public class CustomPDFDocumentFactory {
* Determine the appropriate caching strategy based on file size and available memory. This * Determine the appropriate caching strategy based on file size and available memory. This
* common method is used by both password and non-password loading paths. * common method is used by both password and non-password loading paths.
*/ */
private StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
long maxMemory = Runtime.getRuntime().maxMemory(); long maxMemory = Runtime.getRuntime().maxMemory();
long freeMemory = Runtime.getRuntime().freeMemory(); long freeMemory = Runtime.getRuntime().freeMemory();
long totalMemory = Runtime.getRuntime().totalMemory(); long totalMemory = Runtime.getRuntime().totalMemory();

View File

@ -64,10 +64,19 @@ public class PdfMetadataService {
String creator = stirlingPDFLabel; String creator = stirlingPDFLabel;
if (applicationProperties.getEnterpriseEdition().getCustomMetadata().isAutoUpdateMetadata() if (applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.isAutoUpdateMetadata()
&& runningEE) { && runningEE) {
creator = applicationProperties.getEnterpriseEdition().getCustomMetadata().getCreator(); creator =
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getCreator();
pdf.getDocumentInformation().setProducer(stirlingPDFLabel); pdf.getDocumentInformation().setProducer(stirlingPDFLabel);
} }
@ -84,9 +93,18 @@ public class PdfMetadataService {
pdf.getDocumentInformation().setModificationDate(Calendar.getInstance()); pdf.getDocumentInformation().setModificationDate(Calendar.getInstance());
String author = pdfMetadata.getAuthor(); String author = pdfMetadata.getAuthor();
if (applicationProperties.getEnterpriseEdition().getCustomMetadata().isAutoUpdateMetadata() if (applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.isAutoUpdateMetadata()
&& runningEE) { && runningEE) {
author = applicationProperties.getEnterpriseEdition().getCustomMetadata().getAuthor(); author =
applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getAuthor();
if (userService != null) { if (userService != null) {
author = author.replace("username", userService.getCurrentUsername()); author = author.replace("username", userService.getCurrentUsername());

View File

@ -334,27 +334,40 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"enterpriseEdition_enabled", "enterpriseEdition_enabled",
applicationProperties.getEnterpriseEdition().isEnabled()); applicationProperties.getPremium().isEnabled());
if (applicationProperties.getEnterpriseEdition().isEnabled()) { if (applicationProperties.getPremium().isEnabled()) {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"enterpriseEdition_customMetadata_autoUpdateMetadata", "enterpriseEdition_customMetadata_autoUpdateMetadata",
applicationProperties applicationProperties
.getEnterpriseEdition() .getPremium()
.getProFeatures()
.getCustomMetadata() .getCustomMetadata()
.isAutoUpdateMetadata()); .isAutoUpdateMetadata());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"enterpriseEdition_customMetadata_author", "enterpriseEdition_customMetadata_author",
applicationProperties.getEnterpriseEdition().getCustomMetadata().getAuthor()); applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getAuthor());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"enterpriseEdition_customMetadata_creator", "enterpriseEdition_customMetadata_creator",
applicationProperties.getEnterpriseEdition().getCustomMetadata().getCreator()); applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getCreator());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"enterpriseEdition_customMetadata_producer", "enterpriseEdition_customMetadata_producer",
applicationProperties.getEnterpriseEdition().getCustomMetadata().getProducer()); applicationProperties
.getPremium()
.getProFeatures()
.getCustomMetadata()
.getProducer());
} }
// Capture AutoPipeline properties // Capture AutoPipeline properties
addIfNotEmpty( addIfNotEmpty(

View File

@ -39,6 +39,7 @@ public class RequestUriUtils {
|| requestURI.endsWith(".css") || requestURI.endsWith(".css")
|| requestURI.endsWith(".map") || requestURI.endsWith(".map")
|| requestURI.endsWith(".svg") || requestURI.endsWith(".svg")
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js") || requestURI.endsWith(".js")
|| requestURI.contains("swagger") || requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info") || requestURI.startsWith("/api/v1/info")

View File

@ -40,4 +40,4 @@ springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI # Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs springdoc.swagger-ui.url=/v1/api-docs
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
posthog.host=https://eu.i.posthog.com posthog.host=https://eu.i.posthog.com

View File

@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users:
adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.disabledUsers=Disabled Users:
adminUserSettings.totalUsers=Total Users: adminUserSettings.totalUsers=Total Users:
adminUserSettings.lastRequest=Last Request adminUserSettings.lastRequest=Last Request
adminUserSettings.usage=View Usage
endpointStatistics.title=Endpoint Statistics
endpointStatistics.header=Endpoint Statistics
endpointStatistics.top10=Top 10
endpointStatistics.top20=Top 20
endpointStatistics.all=All
endpointStatistics.refresh=Refresh
endpointStatistics.includeHomepage=Include Homepage ('/')
endpointStatistics.includeLoginPage=Include Login Page ('/login')
endpointStatistics.totalEndpoints=Total Endpoints
endpointStatistics.totalVisits=Total Visits
endpointStatistics.showing=Showing
endpointStatistics.selectedVisits=Selected Visits
endpointStatistics.endpoint=Endpoint
endpointStatistics.visits=Visits
endpointStatistics.percentage=Percentage
endpointStatistics.loading=Loading...
endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing.
endpointStatistics.home=Home
endpointStatistics.login=Login
endpointStatistics.top=Top
endpointStatistics.numberOfVisits=Number of Visits
endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total)
endpointStatistics.retry=Retry
database.title=Database Import/Export database.title=Database Import/Export
database.header=Database Import/Export database.header=Database Import/Export

View File

@ -61,15 +61,17 @@ security:
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
enterpriseEdition:
enabled: false # set to 'true' to enable enterprise edition premium:
key: 00000000-0000-0000-0000-000000000000 key: 00000000-0000-0000-0000-000000000000
SSOAutoLogin: false # Enable to auto login to first provided SSO enabled: false # Enable license key checks for pro/enterprise features
CustomMetadata: proFeatures:
autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values SSOAutoLogin: false
author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username CustomMetadata:
creator: Stirling-PDF # supports text such as 'Company-PDF' autoUpdateMetadata: false
producer: Stirling-PDF # supports text such as 'Company-PDF' author: username
creator: Stirling-PDF
producer: Stirling-PDF
legal: legal:
termsAndConditions: https://www.stirlingpdf.com/terms-and-conditions # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder termsAndConditions: https://www.stirlingpdf.com/terms-and-conditions # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
@ -87,6 +89,7 @@ system:
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. 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: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
datasource: datasource:
enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration

View File

@ -0,0 +1,83 @@
.active-user {
color: green;
text-shadow: 0 0 5px green;
}
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-container {
position: relative;
height: 400px;
width: 100%;
margin-bottom: 20px;
}
.stats-box {
background: var(--md-sys-color-outline-variant);
padding: .8rem;
margin: 10px 0;
border-radius: 2rem;
text-align: center;
}
.chart-controls {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 15px;
flex-wrap: wrap;
}
.filter-checkbox {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.filter-checkbox input {
margin-right: 5px;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
html[data-bs-theme="light"] .loading {
background-color: rgba(255, 255, 255, 0.7);
}
html[data-bs-theme="dark"] .loading {
background-color: rgba(15, 20, 26, 0.7);
}
/* Add some animation to the refresh button */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.refreshing .material-symbols-rounded {
animation: spin 1s linear infinite;
}

View File

@ -0,0 +1,363 @@
// We'll fetch data from the API instead of hardcoding it
let allEndpointData = [];
let filteredData = [];
// We'll store these as global variables that get updated when we fetch data
let sortedData = [];
let totalEndpoints = 0;
let totalVisits = 0;
// Chart instance
let myChart;
// Function to get chart colors based on current theme
function getChartColors() {
var style = window.getComputedStyle(document.body)
const colours = {
textColor: style.getPropertyValue('--md-sys-color-on-surface') ,
primaryColor: style.getPropertyValue('--md-sys-color-primary'),
backgroundColor: style.getPropertyValue('--md-sys-color-background'),
gridColor: style.getPropertyValue('--md-sys-color-on-surface'),
tooltipBgColor: style.getPropertyValue('--md-sys-color-inverse-on-surface'),
tooltipTextColor: style.getPropertyValue('--md-sys-color-inverse-surface')
}
return colours;
}
// Watch for theme changes and update chart if needed
function setupThemeChangeListener() {
// Start observing theme changes
document.addEventListener("modeChanged", (event) => {
setTimeout(function() {
if (myChart) {
const currentLimit = document.getElementById('currentlyShowing').textContent;
const limit = (currentLimit === endpointStatsTranslations.all)
? filteredData.length
: (currentLimit === endpointStatsTranslations.top20 ? 20 : 10);
updateChart(limit);
}
}, 100);
});
// Also watch for system preference changes
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (myChart) {
const currentLimit = document.getElementById('currentlyShowing').textContent;
const limit = (currentLimit === endpointStatsTranslations.all)
? filteredData.length
: (currentLimit === endpointStatsTranslations.top20 ? 20 : 10);
updateChart(limit);
}
});
}
// Function to filter data based on checkbox settings
function filterData() {
const includeHome = document.getElementById('hideHomeCheckbox').checked;
const includeLogin = document.getElementById('hideLoginCheckbox').checked;
filteredData = allEndpointData.filter(item => {
if (!includeHome && item.endpoint === '/') return false;
if (!includeLogin && item.endpoint === '/login') return false;
return true;
});
// Sort and calculate
sortedData = [...filteredData].sort((a, b) => b.count - a.count);
totalEndpoints = filteredData.length;
totalVisits = filteredData.reduce((sum, item) => sum + item.count, 0);
// Update stats
document.getElementById('totalEndpoints').textContent = totalEndpoints.toLocaleString();
document.getElementById('totalVisits').textContent = totalVisits.toLocaleString();
// Update the chart with current limit
const currentLimit = document.getElementById('currentlyShowing').textContent;
const limit = (currentLimit === endpointStatsTranslations.all)
? filteredData.length
: (currentLimit === endpointStatsTranslations.top20 ? 20 : 10);
updateChart(limit);
}
// Function to fetch data from the API
async function fetchEndpointData() {
try {
// Show loading state
const chartContainer = document.querySelector('.chart-container');
const loadingDiv = document.createElement('div');
loadingDiv.className = 'loading';
loadingDiv.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">${endpointStatsTranslations.loading}</span>
</div>`;
chartContainer.appendChild(loadingDiv);
// Also add animation to refresh button
const refreshBtn = document.getElementById('refreshBtn');
refreshBtn.classList.add('refreshing');
refreshBtn.disabled = true;
const response = await fetch('/api/v1/info/load/all');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
allEndpointData = data;
// Apply filters
filterData();
// Remove loading state
chartContainer.removeChild(loadingDiv);
refreshBtn.classList.remove('refreshing');
refreshBtn.disabled = false;
} catch (error) {
console.error('Error fetching endpoint data:', error);
// Show error message to user
showError(endpointStatsTranslations.failedToLoad);
// Reset refresh button
const refreshBtn = document.getElementById('refreshBtn');
refreshBtn.classList.remove('refreshing');
refreshBtn.disabled = false;
}
}
// Function to format endpoint names
function formatEndpointName(endpoint) {
if (endpoint === '/') return endpointStatsTranslations.home;
if (endpoint === '/login') return endpointStatsTranslations.login;
return endpoint.replace('/', '').replace(/-/g, ' ');
}
// Function to update the table
function updateTable(data) {
const tableBody = document.getElementById('endpointTableBody');
tableBody.innerHTML = '';
data.forEach((item, index) => {
const percentage = ((item.count / totalVisits) * 100).toFixed(2);
const row = document.createElement('tr');
// Format endpoint for better readability
let displayEndpoint = item.endpoint;
if (displayEndpoint.length > 40) {
displayEndpoint = displayEndpoint.substring(0, 37) + '...';
}
row.innerHTML = `
<td>${index + 1}</td>
<td title="${item.endpoint}">${displayEndpoint}</td>
<td>${item.count.toLocaleString()}</td>
<td>${percentage}%</td>
`;
tableBody.appendChild(row);
});
}
// Function to update the chart
function updateChart(dataLimit) {
const chartData = sortedData.slice(0, dataLimit);
// Calculate displayed statistics
const displayedVisits = chartData.reduce((sum, item) => sum + item.count, 0);
const displayedPercentage = totalVisits > 0
? ((displayedVisits / totalVisits) * 100).toFixed(2)
: '0';
document.getElementById('displayedVisits').textContent = displayedVisits.toLocaleString();
document.getElementById('displayedPercentage').textContent = displayedPercentage;
// If the limit equals the total filtered items, show "All"; otherwise "Top X"
document.getElementById('currentlyShowing').textContent =
(dataLimit === filteredData.length)
? endpointStatsTranslations.all
: endpointStatsTranslations.top + dataLimit;
// Update the table with new data
updateTable(chartData);
// Prepare labels and datasets
const labels = chartData.map(item => formatEndpointName(item.endpoint));
const data = chartData.map(item => item.count);
// Get theme-specific colors
const colors = getChartColors();
// Destroy previous chart if it exists
if (myChart) {
myChart.destroy();
}
// Create chart context
const ctx = document.getElementById('endpointChart').getContext('2d');
// Create new chart with theme-appropriate colors
myChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: endpointStatsTranslations.numberOfVisits,
data: data,
backgroundColor: colors.primaryColor.replace('rgb', 'rgba').replace(')', ', 0.6)'),
borderColor: colors.primaryColor,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: dataLimit > 20 ? 'x' : 'y',
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: colors.primaryColor,
font: {
weight: 'bold'
}
}
},
tooltip: {
backgroundColor: colors.tooltipBgColor,
titleColor: colors.tooltipTextColor,
bodyColor: colors.tooltipTextColor,
borderColor: colors.tooltipBgColor,
borderWidth: 1,
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
},
callbacks: {
label: (context) => {
const value = context.raw;
const percentage = totalVisits > 0
? ((value / totalVisits) * 100).toFixed(2)
: '0';
// Insert your i18n text in the final string:
// e.g. "Visits: 12 (34% of total)"
// If your translation includes placeholders, you'd parse them here:
return endpointStatsTranslations.visitsTooltip
.replace('{0}', value.toLocaleString())
.replace('{1}', percentage);
}
}
}
},
scales: {
x: {
border: {
color: colors.gridColor
},
ticks: {
color: colors.gridColor,
font: {
size: 12
},
callback: function(value, index, values) {
let label = this.getLabelForValue(value);
return label.length > 15 ? label.substr(0, 15) + '...' : label;
}
},
grid: {
color: `${colors.gridColor}`
}
},
y: {
border: {
color: colors.gridColor
},
min: 0,
ticks: {
color: colors.gridColor,
font: {
size: 12
},
precision: 0
},
grid: {
color: `${colors.gridColor}`
}
}
}
}
});
}
// Initialize with fetch and top 10
document.addEventListener('DOMContentLoaded', function() {
// Set up theme change listener
setupThemeChangeListener();
// Initial data fetch
fetchEndpointData();
// Set up button event listeners
document.getElementById('top10Btn').addEventListener('click', function() {
updateChart(10);
setActiveButton(this);
});
document.getElementById('top20Btn').addEventListener('click', function() {
updateChart(20);
setActiveButton(this);
});
document.getElementById('allBtn').addEventListener('click', function() {
updateChart(filteredData.length);
setActiveButton(this);
});
document.getElementById('refreshBtn').addEventListener('click', function() {
fetchEndpointData();
});
// Set up filter checkbox listeners
document.getElementById('hideHomeCheckbox').addEventListener('change', filterData);
document.getElementById('hideLoginCheckbox').addEventListener('change', filterData);
});
function setActiveButton(activeButton) {
// Remove active class from all buttons
document.querySelectorAll('.chart-controls button').forEach(button => {
button.classList.remove('active');
});
// Add active class to clicked button
activeButton.classList.add('active');
}
// Function to handle errors in a user-friendly way
function showError(message) {
const chartContainer = document.querySelector('.chart-container');
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.innerHTML = `
<span class="material-symbols-rounded" style="vertical-align: bottom; margin-right: 5px;">error</span>
${message}
<button id="errorRetryBtn" class="btn btn-outline-danger btn-sm" style="margin-left: 10px;">
<span class="material-symbols-rounded" style="font-size: 1rem; vertical-align: bottom;">refresh</span>
${endpointStatsTranslations.retry}
</button>
`;
chartContainer.innerHTML = '';
chartContainer.appendChild(errorDiv);
// Add retry button functionality
document.getElementById('errorRetryBtn').addEventListener('click', fetchEndpointData);
}

View File

@ -349,7 +349,7 @@
</script> </script>
<div class="mb-3 mt-4 text-center"> <div class="mb-3 mt-4 text-center">
<a th:href="@{'/logout'}" role="button" class="btn btn-danger" th:text="#{account.signOut}">Sign Out</a> <a th:href="@{'/logout'}" role="button" class="btn btn-danger" th:text="#{account.signOut}">Sign Out</a>
<a th:if="${role == 'ROLE_ADMIN'}" class="btn btn-info" th:href="@{'/addUsers'}" role="button" th:text="#{account.adminSettings}" target="_blank">Admin Settings</a> <a th:if="${role == 'ROLE_ADMIN'}" class="btn btn-info" th:href="@{'/adminSettings'}" role="button" th:text="#{account.adminSettings}" target="_blank">Admin Settings</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -32,21 +32,46 @@
</div> </div>
<!-- User Settings Title --> <!-- User Settings Title -->
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;"> <div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
<a href="#" data-bs-toggle="modal" data-bs-target="#addUserModal" class="btn btn-outline-success" th:title="#{adminUserSettings.addUser}"> <a href="#"
<span class="material-symbols-rounded">person_add</span> th:data-bs-toggle="${@runningEE && totalUsers >= maxEnterpriseUsers} ? null : 'modal'"
<span th:text="#{adminUserSettings.addUser}">Add New User</span> th:data-bs-target="${@runningEE && totalUsers >= maxEnterpriseUsers} ? null : '#addUserModal'"
</a> th:class="${@runningEE && totalUsers >= maxEnterpriseUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
<a href="#" data-bs-toggle="modal" data-bs-target="#changeUserRoleModal" class="btn btn-outline-success" th:title="#{adminUserSettings.changeUserRole}"> th:title="${@runningEE && totalUsers >= maxEnterpriseUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
<span class="material-symbols-rounded">edit</span> <span class="material-symbols-rounded">person_add</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span> <span th:text="#{adminUserSettings.addUser}">Add New User</span>
</a> </a>
<div class="my-4">
<strong th:text="#{adminUserSettings.totalUsers}">Total Users:</strong> <span th:text="${totalUsers}"></span> <a href="#"
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong> <span th:text="${activeUsers}"></span> data-bs-toggle="modal"
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong> <span th:text="${disabledUsers}"></span> data-bs-target="#changeUserRoleModal"
</div> class="btn btn-outline-success"
</div> th:title="#{adminUserSettings.changeUserRole}">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</a>
<a href="/usage"
class="btn btn-outline-success"
th:title="#{adminUserSettings.usage}">
<span class="material-symbols-rounded">analytics</span>
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
</a>
<div class="my-4">
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
<span th:text="${totalUsers}"></span>
<span th:if="${@runningEE}" th:text="'/'+${maxEnterpriseUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
<span th:text="${activeUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
<span th:text="${disabledUsers}"></span>
</div>
</div>
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;"> <div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<div class="alert alert-danger mb-auto"> <div class="alert alert-danger mb-auto">
<span th:text="#{${addMessage}}">Default message if not found</span> <span th:text="#{${addMessage}}">Default message if not found</span>
@ -101,7 +126,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<p th:text="#{enterpriseEdition.ssoAdvert}"></p> <p th:if="${!@runningEE}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
<script th:inline="javascript"> <script th:inline="javascript">
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?'; const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';

View File

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{endpointStatistics.title}, header=#{endpointStatistics.header})}"></th:block>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<link rel="stylesheet" th:href="@{'/css/usage.css'}">
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon">analytics</span>
<span class="tool-header-text" th:text="#{endpointStatistics.header}">Endpoint Statistics</span>
</div>
<!-- Statistics Summary Box -->
<div class="stats-box">
<div class="chart-controls">
<button id="top10Btn" class="btn btn-outline-primary active">
<span class="material-symbols-rounded">bar_chart</span>
<span th:text="#{endpointStatistics.top10}">Top 10</span>
</button>
<button id="top20Btn" class="btn btn-outline-primary">
<span class="material-symbols-rounded">data_usage</span>
<span th:text="#{endpointStatistics.top20}">Top 20</span>
</button>
<button id="allBtn" class="btn btn-outline-primary">
<span class="material-symbols-rounded">insights</span>
<span th:text="#{endpointStatistics.all}">All</span>
</button>
<button id="refreshBtn" class="btn btn-outline-secondary">
<span class="material-symbols-rounded">refresh</span>
<span th:text="#{endpointStatistics.refresh}">Refresh</span>
</button>
</div>
<div class="filter-controls">
<label class="filter-checkbox">
<input type="checkbox" id="hideHomeCheckbox" checked>
<span th:text="#{endpointStatistics.includeHomepage}">Include Homepage ('/')</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="hideLoginCheckbox" checked>
<span th:text="#{endpointStatistics.includeLoginPage}">Include Login Page ('/login')</span>
</label>
</div>
<div class="my-4" style="color: var(--md-sys-color-on-surface); font-weight: 500;">
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.totalEndpoints} + ':'">Total Endpoints:</strong> <span id="totalEndpoints">0</span></span>
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.totalVisits} + ':'">Total Visits:</strong> <span id="totalVisits">0</span></span>
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.showing} + ':'">Showing:</strong> <span id="currentlyShowing">Top 10</span></span>
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.selectedVisits} + ':'">Selected Visits:</strong> <span id="displayedVisits">0</span> (<span id="displayedPercentage">0</span>%)</span>
</div>
</div>
<!-- Chart Container -->
<div class="bg-card mt-3 mb-3">
<div class="chart-container">
<canvas id="endpointChart"></canvas>
</div>
</div>
<!-- Table for detailed data -->
<div class="bg-card mt-3 mb-3">
<table class="endpoint-table">
<thead>
<tr>
<th scope="col" style="width: 5%;">#</th>
<th scope="col" style="width: 55%;" th:text="#{endpointStatistics.endpoint}">Endpoint</th>
<th scope="col" style="width: 20%;" th:text="#{endpointStatistics.visits}">Visits</th>
<th scope="col" style="width: 20%;" th:text="#{endpointStatistics.percentage}">Percentage</th>
</tr>
</thead>
<tbody id="endpointTableBody">
<!-- Table rows will be dynamically generated -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" th:src="@{'/js/usage.js'}"></script>
<script th:inline="javascript">
const endpointStatsTranslations = {
all: /*[[#{endpointStatistics.all}]]*/ 'All',
top20: /*[[#{endpointStatistics.top20}]]*/ 'Top 20',
loading: /*[[#{endpointStatistics.loading}]]*/ 'Loading...',
failedToLoad: /*[[#{endpointStatistics.failedToLoad}]]*/ 'Failed to load endpoint data. Please try refreshing.',
home: /*[[#{endpointStatistics.home}]]*/ 'Home',
login: /*[[#{endpointStatistics.login}]]*/ 'Login',
top: /*[[#{endpointStatistics.top}]]*/ 'Top ',
numberOfVisits: /*[[#{endpointStatistics.numberOfVisits}]]*/ 'Number of Visits',
visitsTooltip: /*[[#{endpointStatistics.visitsTooltip}]]*/ 'Visits: {0} ({1}% of total)',
retry: /*[[#{endpointStatistics.retry}]]*/ 'Retry'
};
</script>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@ -9,6 +9,7 @@ import org.mockito.Mock;
import org.mockito.MockitoAnnotations; import org.mockito.MockitoAnnotations;
import stirling.software.SPDF.config.RuntimePathConfig; import stirling.software.SPDF.config.RuntimePathConfig;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest; import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.SPDF.service.CustomPDFDocumentFactory; import stirling.software.SPDF.service.CustomPDFDocumentFactory;
@ -18,12 +19,18 @@ public class ConvertWebsiteToPdfTest {
@Mock private RuntimePathConfig runtimePathConfig; @Mock private RuntimePathConfig runtimePathConfig;
private ApplicationProperties applicationProperties;
private ConvertWebsiteToPDF convertWebsiteToPDF; private ConvertWebsiteToPDF convertWebsiteToPDF;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
convertWebsiteToPDF = new ConvertWebsiteToPDF(mockPdfDocumentFactory, runtimePathConfig); applicationProperties = new ApplicationProperties();
applicationProperties.getSystem().setEnableUrlToPDF(true);
convertWebsiteToPDF =
new ConvertWebsiteToPDF(
mockPdfDocumentFactory, runtimePathConfig, applicationProperties);
} }
@Test @Test

View File

@ -60,7 +60,6 @@
/split-pdf-by-sections /split-pdf-by-sections
/split-pdfs /split-pdfs
/stamp /stamp
/url-to-pdf
/validate-signature /validate-signature
/view-pdf /view-pdf
/swagger-ui/index.html /swagger-ui/index.html