#1869 and ensure naming

Signed-off-by: a <a>
This commit is contained in:
a 2024-09-20 13:31:55 +01:00
parent 87925ac618
commit 4436759e12
14 changed files with 467 additions and 28 deletions

View File

@ -0,0 +1,182 @@
package stirling.software.SPDF.EE;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@Service
public class KeygenLicenseVerifier {
private static final String ACCOUNT_ID = "e5430f69-e834-4ae4-befd-b602aae5f372";
private static final String PRODUCT_ID = "f9bb2423-62c9-4d39-8def-4fdc5aca751e";
private static final String BASE_URL = "https://api.keygen.sh/v1/accounts";
private static final String PUBLIC_KEY =
"-----BEGIN PUBLIC KEY-----\n"
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJaf7jPx/bamT/ctmvrf\n"
+ "5HfzV9CrTx39Hv48NvRIjw9jBAlmcSndLbgcrTUWFrd7pJPPEhzmfJ9tLRg0a3Si\n"
+ "34Ed9gQ24mODj0Wpos5uwwxu1M5wzsKPjkLZDigB3d9L/79nyKvSUo+mx+dZmZnD\n"
+ "D19TMM93ZDxG+Bru5/rvvxaZzMHZAnqrTdoO55vFjpss5XJNt6kz4jxr+D6a3lFU\n"
+ "GGCx7bjeanHCNGRw84dLYbU8s5DGsx5JNX1xPGR1kODocvsHfHJvsxfdNtpH4vke\n"
+ "yOrtEUCp01Mh2kr3zM8R4Yjh4ae2qHiZne0FiVhiUaHmbf2dmcA9O1Kynz33634s\n"
+ "fwIDAQAB\n"
+ "-----END PUBLIC KEY-----";
private static final ObjectMapper objectMapper = new ObjectMapper();
public static boolean verifyLicense(String licenseKey) {
try {
String machineFingerprint = generateMachineFingerprint();
// First, try to validate the license
boolean isValid = validateLicense(licenseKey, machineFingerprint);
// If validation fails, try to activate the machine
if (!isValid) {
System.out.println(
"License validation failed. Attempting to activate the machine...");
isValid = activateMachine(licenseKey, machineFingerprint);
if (isValid) {
// If activation is successful, try to validate again
isValid = validateLicense(licenseKey, machineFingerprint);
}
}
return isValid;
} catch (Exception e) {
System.out.println("Error verifying license: " + e.getMessage());
return false;
}
}
private static boolean validateLicense(String licenseKey, String machineFingerprint)
throws Exception {
HttpClient client = HttpClient.newHttpClient();
String requestBody =
String.format(
"{\"meta\":{\"key\":\"%s\",\"scope\":{\"fingerprint\":\"%s\",\"product\":\"%s\"}}}",
licenseKey, machineFingerprint, PRODUCT_ID);
HttpRequest request =
HttpRequest.newBuilder()
.uri(
URI.create(
BASE_URL
+ "/"
+ ACCOUNT_ID
+ "/licenses/actions/validate-key"))
.header("Content-Type", "application/vnd.api+json")
.header("Accept", "application/vnd.api+json")
.header("Authorization", "license " + licenseKey)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode jsonResponse = objectMapper.readTree(response.body());
JsonNode metaNode = jsonResponse.path("meta");
boolean isValid = metaNode.path("valid").asBoolean();
String detail = metaNode.path("detail").asText();
String code = metaNode.path("code").asText();
System.out.println("License validity: " + isValid);
System.out.println("Validation detail: " + detail);
System.out.println("Validation code: " + code);
if (isValid) {
return verifySignature(metaNode);
}
} else {
System.out.println("Error validating license. Status code: " + response.statusCode());
System.out.println("Response body: " + response.body());
}
return false;
}
private static boolean activateMachine(String licenseKey, String machineFingerprint)
throws Exception {
HttpClient client = HttpClient.newHttpClient();
String requestBody =
String.format(
"{\"data\":{\"type\":\"machines\",\"attributes\":{\"fingerprint\":\"%s\"},\"relationships\":{\"license\":{\"data\":{\"type\":\"licenses\",\"id\":\"%s\"}}}}}",
machineFingerprint, licenseKey);
String licenseId = "8e072b67-3cea-454b-98bb-bb73bbc04bd4";
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines"))
.header("Content-Type", "application/vnd.api+json")
.header("Accept", "application/vnd.api+json")
.header("Authorization", "license " + licenseId)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 201) {
System.out.println("Machine activated successfully");
return true;
} else {
System.out.println("Error activating machine. Status code: " + response.statusCode());
System.out.println("Response body: " + response.body());
return false;
}
}
private static boolean verifySignature(JsonNode metaNode) throws Exception {
String signature = metaNode.path("signature").asText();
String data = metaNode.path("data").asText();
PublicKey publicKey =
KeyFactory.getInstance("RSA")
.generatePublic(
new X509EncodedKeySpec(
Base64.getDecoder()
.decode(
PUBLIC_KEY
.replace(
"-----BEGIN PUBLIC KEY-----",
"")
.replace(
"-----END PUBLIC KEY-----",
"")
.replaceAll("\\s", ""))));
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
sig.update(data.getBytes());
boolean isSignatureValid = sig.verify(Base64.getDecoder().decode(signature));
System.out.println("Signature validity: " + isSignatureValid);
return isSignatureValid;
}
private static String generateMachineFingerprint() {
// This is a simplified example. In a real-world scenario, you'd want to generate
// a more robust and unique fingerprint based on hardware characteristics.
return "example-fingerprint-" + System.currentTimeMillis();
}
public static void test() {
String[] testKeys = {
"FYKJ-YK7F-MEVX-RYKK-JYWE-77WW-3TKN-PJRU", "EFDB57-92B4C2-EDFA20-51146E-E1AF4A-V3"
};
for (String licenseKey : testKeys) {
System.out.println("Testing license key: " + licenseKey);
boolean isValid = verifyLicense(licenseKey);
System.out.println("License is valid: " + isValid);
System.out.println("--------------------");
}
}
}

View File

@ -0,0 +1,58 @@
package stirling.software.SPDF.EE;
import org.springframework.boot.CommandLineRunner;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import stirling.software.SPDF.model.ApplicationProperties;
@Component
public class LicenseKeyChecker implements CommandLineRunner {
private final KeygenLicenseVerifier licenseService;
private final ApplicationProperties applicationProperties;
// Inject your license service or configuration
public LicenseKeyChecker(
KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) {
this.licenseService = licenseService;
this.applicationProperties = new ApplicationProperties();
}
// Validate on startup
@Override
public void run(String... args) throws Exception {
checkLicense();
}
// Periodic license check - runs every 7 days
@Scheduled(fixedRate = 604800000) // 7 days in milliseconds
public void checkLicensePeriodically() {
checkLicense();
}
// License validation logic
private void checkLicense() {
boolean isValid =
licenseService.verifyLicense(applicationProperties.getEnterpriseEdition().getKey());
if (!isValid) {
// Handle invalid license (shut down the app, log, etc.)
System.out.println("License key is invalid!");
// Optionally stop the application
// System.exit(1); // Uncomment if you want to stop the app
} else {
System.out.println("License key is valid.");
}
}
// Method to update the license key dynamically
public void updateLicenseKey(String newKey) {
// Update the key in ApplicationProperties
applicationProperties.getEnterpriseEdition().setKey(newKey);
// Immediately validate the new key
System.out.println("License key has been updated. Checking new key...");
checkLicense();
}
}

View File

@ -13,6 +13,7 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import stirling.software.SPDF.utils.RequestUriUtils;
@Component
@ -32,10 +33,11 @@ public class MetricsFilter extends OncePerRequestFilter {
String uri = request.getRequestURI();
if (RequestUriUtils.isTrackableResource(request.getContextPath(), uri)) {
HttpSession session = request.getSession(false);
String sessionId = (session != null) ? session.getId() : "no-session";
Counter counter =
Counter.builder("http.requests")
.tag("session", request.getSession().getId())
.tag("session", sessionId)
.tag("method", request.getMethod())
.tag("uri", uri)
.register(meterRegistry);

View File

@ -1,6 +1,8 @@
package stirling.software.SPDF.config.security;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
@ -14,9 +16,12 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.utils.RequestUriUtils;
@Slf4j
@Component
public class FirstLoginFilter extends OncePerRequestFilter {
@ -50,6 +55,20 @@ public class FirstLoginFilter extends OncePerRequestFilter {
return;
}
}
HttpSession session = request.getSession(true);
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
String creationTime = timeFormat.format(new Date(session.getCreationTime()));
log.info(
"Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}",
session.isNew(),
creationTime,
session.getId(),
request.getRemoteAddr(),
request.getHeader("User-Agent"),
request.getHeader("Referer"),
request.getRequestURL().toString());
filterChain.doFilter(request, response);
}
}

View File

@ -5,7 +5,6 @@ import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpStatus;
@ -30,13 +29,18 @@ import stirling.software.SPDF.model.User;
@Component
public class UserAuthenticationFilter extends OncePerRequestFilter {
@Autowired @Lazy private UserService userService;
private final UserService userService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
@Autowired private SessionPersistentRegistry sessionPersistentRegistry;
@Autowired
@Qualifier("loginEnabled")
public boolean loginEnabledValue;
public UserAuthenticationFilter(
@Lazy UserService userService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
this.userService = userService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
}
@Override
protected void doFilterInternal(
@ -51,6 +55,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// Check for session expiration (unsure if needed)
// if (authentication != null && authentication.isAuthenticated()) {
// String sessionId = request.getSession().getId();
// SessionInformation sessionInfo =
// sessionPersistentRegistry.getSessionInformation(sessionId);
//
// if (sessionInfo != null && sessionInfo.isExpired()) {
// SecurityContextHolder.clearContext();
// response.sendRedirect(request.getContextPath() + "/login?expired=true");
// return;
// }
// }
// Check for API key in the request headers if no authentication exists
if (authentication == null || !authentication.isAuthenticated()) {
String apiKey = request.getHeader("X-API-Key");

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.config.security.session;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@ -11,16 +13,32 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CustomHttpSessionListener implements HttpSessionListener {
@Autowired private SessionPersistentRegistry sessionPersistentRegistry;
private SessionPersistentRegistry sessionPersistentRegistry;
private final AtomicInteger activeSessions;
@Autowired
public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) {
super();
this.sessionPersistentRegistry = sessionPersistentRegistry;
activeSessions = new AtomicInteger();
}
@Override
public void sessionCreated(HttpSessionEvent se) {
log.info("Session created: " + se.getSession().getId());
log.info(
"Session created: {} with count {}",
se.getSession().getId(),
activeSessions.incrementAndGet());
}
@Override
public void sessionDestroyed(HttpSessionEvent se) {
log.info("Session destroyed: " + se.getSession().getId());
log.info(
"Session destroyed: {} with count {}",
se.getSession().getId(),
activeSessions.decrementAndGet());
sessionPersistentRegistry.expireSession(se.getSession().getId());
}
}

View File

@ -84,6 +84,14 @@ public class SessionPersistentRegistry implements SessionRegistry {
}
if (principalName != null) {
// Clear old sessions for the principal (unsure if needed)
// List<SessionEntity> existingSessions =
// sessionRepository.findByPrincipalName(principalName);
// for (SessionEntity session : existingSessions) {
// session.setExpired(true);
// sessionRepository.save(session);
// }
SessionEntity sessionEntity = new SessionEntity();
sessionEntity.setSessionId(sessionId);
sessionEntity.setPrincipalName(principalName);

View File

@ -108,6 +108,13 @@ public class GeneralWebController {
return "split-pdf-by-sections";
}
@GetMapping("/split-pdf-by-chapters")
@Hidden
public String splitPdfByChapters(Model model) {
model.addAttribute("currentPage", "split-pdf-by-chapters");
return "split-pdf-by-chapters";
}
@GetMapping("/view-pdf")
@Hidden
public String ViewPdfForm2(Model model) {

View File

@ -30,6 +30,7 @@ spring.devtools.livereload.enabled=true
spring.thymeleaf.encoding=UTF-8
spring.web.resources.mime-mappings.webmanifest=application/manifest+json
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
#spring.thymeleaf.prefix=file:/customFiles/templates/,classpath:/templates/

View File

@ -223,6 +223,8 @@ database.fileNotFound=File not found
database.fileNullOrEmpty=File must not be null or empty
database.failedImportFile=Failed to import file
session.expired=Your session has expired. Please refresh the page and try again.
#############
# HOME-PAGE #
#############
@ -480,6 +482,9 @@ home.removeImagePdf.title=Remove image
home.removeImagePdf.desc=Remove image from PDF to reduce file size
removeImagePdf.tags=Remove Image,Page operations,Back end,server side
home.splitPdfByChapters.title=Split PDF by Chapters
home.splitPdfByChapters.desc=Split a PDF into multiple files based on its chapter structure.
splitPdfByChapters.tags=split,chapters,bookmarks,organize
###########################
# #
@ -1132,7 +1137,9 @@ licenses.license=Licence
survey.nav=Survey
survey.title=Stirling-PDF Survey
survey.description=Stirling-PDF has no tracking so we want to hear from our users to improve Stirling-PDF!
survey.please=Please consider taking our survey!
survey.changes=Stirling-PDF has changed since the last survey! To find out more please check our blog post here:
survey.changes2=With these changes we are getting paid business support and funding
survey.please=Please consider taking our survey to have input on the future of Stirling-PDF!
survey.disabled=(Survey popup will be disabled in following updates but available at foot of page)
survey.button=Take Survey
survey.dontShowAgain=Don't show again
@ -1157,3 +1164,17 @@ removeImage.title=Remove image
removeImage.header=Remove image
removeImage.removeImage=Remove image
removeImage.submit=Remove image
splitByChapters.title=Split PDF by Chapters
splitByChapters.header=Split PDF by Chapters
splitByChapters.bookmarkLevel=Bookmark Level
splitByChapters.includeMetadata=Include Metadata
splitByChapters.allowDuplicates=Allow Duplicates
splitByChapters.desc.1=This tool splits a PDF file into multiple PDFs based on its chapter structure.
splitByChapters.desc.2=Bookmark Level: Choose the level of bookmarks to use for splitting (0 for top-level, 1 for second-level, etc.).
splitByChapters.desc.3=Include Metadata: If checked, the original PDF's metadata will be included in each split PDF.
splitByChapters.desc.4=Allow Duplicates: If checked, allows multiple bookmarks on the same page to create separate PDFs.
splitByChapters.submit=Split PDF

View File

@ -5,6 +5,22 @@ function showErrorBanner(message, stackTrace) {
document.querySelector("#errorContainer p").textContent = message;
document.querySelector("#traceContent").textContent = stackTrace;
}
function showSessionExpiredPrompt() {
const errorContainer = document.getElementById("errorContainer");
errorContainer.style.display = "block";
document.querySelector("#errorContainer .alert-heading").textContent = "Session Expired";
document.querySelector("#errorContainer p").textContent = sessionExpired;
document.querySelector("#traceContent").textContent = "";
// Optional: Add a refresh button
const refreshButton = document.createElement("button");
refreshButton.textContent = "Refresh Page";
refreshButton.className = "btn btn-primary mt-3";
refreshButton.onclick = () => location.reload();
errorContainer.appendChild(refreshButton);
}
let firstErrorOccurred = false;
$(document).ready(function () {
@ -79,6 +95,11 @@ async function handleSingleDownload(url, formData, isMulti = false, isZip = fals
const contentType = response.headers.get("content-type");
if (!response.ok) {
if (response.status === 401) {
// Handle 401 Unauthorized error
showSessionExpiredPrompt();
return;
}
if (contentType && contentType.includes("application/json")) {
console.error("Throwing error banner, response was not okay");
return handleJsonResponse(response);
@ -97,7 +118,7 @@ async function handleSingleDownload(url, formData, isMulti = false, isZip = fals
}
} catch (error) {
console.error("Error in handleSingleDownload:", error);
throw error; // Re-throw the error if you want it to be handled higher up.
throw error;
}
}

View File

@ -148,6 +148,7 @@
const multipleInputsForSingleRequest = /*[[${multipleInputsForSingleRequest}]]*/ false;
const disableMultipleFiles = /*[[${disableMultipleFiles}]]*/ false;
const remoteCall = /*[[${remoteCall}]]*/ true;
const sessionExpired = /*[[#{session.expired}]]*/ '';
</script>
<script th:src="@{'/js/downloader.js'}"></script>

View File

@ -7,9 +7,11 @@
</head>
<body>
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<!-- Jumbotron -->
<div class="p-5 rounded d-none d-md-block" id="jumbotron">
<div class="container">
@ -232,6 +234,9 @@
<div
th:replace="~{fragments/card :: card(id='remove-image-pdf', cardTitle=#{home.removeImagePdf.title}, cardText=#{home.removeImagePdf.desc}, cardLink='remove-image-pdf', toolIcon='remove_selection', tags=#{removeImagePdf.tags}, toolGroup='other')}">
</div>
<div
th:replace="~{fragments/card :: card(id='split-pdf-by-chapters', cardTitle=#{home.splitPdfByChapters.title}, cardText=#{home.splitPdfByChapters.desc}, cardLink='split-pdf-by-chapters', toolIcon='book', tags=#{splitPdfByChapters.tags}, toolGroup='organize')}">
</div>
</div>
</div>
</div>
@ -248,7 +253,9 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p th:text="#{survey.description}">Stirling-PDF has no tracking so we want to hear from our users to improve Stirling-PDF!</h5>
<p th:text="#{survey.changes}">Stirling-PDF has changed since the last survey! To find out more please check our blog post here:</h5>
<a href="https://stirlingpdf.info/blog/stirling-pdf-survey-results" target="_blank" th:text="#{survey.changes2}">https://stirlingpdf.info/blog/stirling-pdf-survey-results</a>
<p th:text="#{survey.changes2}">With these changes we are getting paid business support and funding</p>
<p th:text="#{survey.please}">Please consider taking our survey!</p>
<p th:text="#{survey.disabled}">Survey popup will be disabled in following updates but available at foot of page)</p>
<a href="https://stirlingpdf.info/s/clwzgtfw7000gltkmwz1n212m" target="_blank" class="btn btn-primary" id="takeSurvey"th:text="#{survey.button}" >Take Survey</a>
@ -267,17 +274,34 @@
</div>
<script>
/*
document.addEventListener("DOMContentLoaded", function() {
const surveyVersion = "1.1";
const surveyVersion = "2.0";
const modal = new bootstrap.Modal(document.getElementById('surveyModal'));
const dontShowAgain = document.getElementById('dontShowAgain');
const takeSurveyButton = document.getElementById('takeSurvey');
if (localStorage.getItem('surveyVersion') !== surveyVersion || !localStorage.getItem('dontShowSurvey')) {
modal.show();
const viewThresholds = [5, 15, 30, 50, 75, 100, 150, 200];
let pageViews = parseInt(localStorage.getItem('pageViews') || '0');
pageViews++;
localStorage.setItem('pageViews', pageViews.toString());
function shouldShowSurvey() {
if (localStorage.getItem('dontShowSurvey') === 'true' || localStorage.getItem('surveyTaken') === 'true') {
return false;
}
if (localStorage.getItem('surveyVersion') !== surveyVersion) {
return true;
}
return viewThresholds.includes(pageViews);
}
if (shouldShowSurvey()) {
modal.show();
}
dontShowAgain.addEventListener('change', function() {
if (this.checked) {
@ -290,15 +314,11 @@
});
takeSurveyButton.addEventListener('click', function() {
localStorage.setItem('dontShowSurvey', 'true');
localStorage.setItem('surveyTaken', 'true');
localStorage.setItem('surveyVersion', surveyVersion);
modal.hide();
});
if (localStorage.getItem('dontShowSurvey')) {
modal.hide();
}
});*/
});
</script>

View File

@ -0,0 +1,64 @@
<!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=#{splitByChapters.title}, header=#{splitByChapters.header})}"></th:block>
</head>
<body>
<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-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">book</span>
<span class="tool-header-text" th:text="#{splitByChapters.header}"></span>
</div>
<form th:action="@{'/api/v1/general/split-pdf-by-chapters'}" method="post" enctype="multipart/form-data">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}">
</div>
<div class="mb-3">
<label for="bookmarkLevel" th:text="#{splitByChapters.bookmarkLevel}"></label>
<input type="number" class="form-control" id="bookmarkLevel" name="bookmarkLevel" min="0" value="0" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="includeMetadata" name="includeMetadata">
<label class="form-check-label" for="includeMetadata" th:text="#{splitByChapters.includeMetadata}"></label>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="allowDuplicates" name="allowDuplicates">
<label class="form-check-label" for="allowDuplicates" th:text="#{splitByChapters.allowDuplicates}"></label>
</div>
<p>
<a class="btn btn-outline-primary" data-bs-toggle="collapse" href="#info" role="button"
aria-expanded="false" aria-controls="info" th:text="#{info}"></a>
</p>
<div class="collapse" id="info">
<p th:text="#{splitByChapters.desc.1}"></p>
<p th:text="#{splitByChapters.desc.2}"></p>
<p th:text="#{splitByChapters.desc.3}"></p>
<p th:text="#{splitByChapters.desc.4}"></p>
</div>
<br>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{splitByChapters.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>