diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 44a234e3b..3719c0ad8 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -78,6 +78,7 @@ labels: - 'app/core/src/main/resources/banner.txt' - 'app/core/src/main/resources/static/python/png_to_webp.py' - 'app/core/src/main/resources/static/python/split_photos.py' + - 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**' - 'application.properties' - label: 'Security' diff --git a/Dockerfile b/Dockerfile index fe427fea9..4ea6316ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8 # Copy necessary files COPY scripts /scripts -COPY pipeline /pipeline COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ COPY app/core/build/libs/*.jar app.jar diff --git a/Dockerfile.fat b/Dockerfile.fat index 87cb5121c..fd5964baf 100644 --- a/Dockerfile.fat +++ b/Dockerfile.fat @@ -26,7 +26,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8 # Copy necessary files COPY scripts /scripts -COPY pipeline /pipeline COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ # first /app directory is for the build stage, second is for the final image COPY --from=build /app/app/core/build/libs/*.jar app.jar diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite index 85a9ab0ca..acc62b93b 100644 --- a/Dockerfile.ultra-lite +++ b/Dockerfile.ultra-lite @@ -21,7 +21,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/installFonts.sh /scripts/installFonts.sh -COPY pipeline /pipeline COPY app/core/build/libs/*.jar app.jar # Set up necessary directories and permissions @@ -39,10 +38,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et su-exec \ openjdk21-jre && \ # User permissions - mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \ + mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \ chmod +x /scripts/*.sh && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \ chown stirlingpdfuser:stirlingpdfgroup /app.jar # Set environment variables diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 08618329d..247a012ad 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -15,6 +15,7 @@ public class InstallationPathConfig { private static final String CUSTOM_FILES_PATH; private static final String CLIENT_WEBUI_PATH; private static final String SCRIPTS_PATH; + private static final String PIPELINE_PATH; // Config paths private static final String SETTINGS_PATH; @@ -33,6 +34,7 @@ public class InstallationPathConfig { CONFIG_PATH = BASE_PATH + "configs" + File.separator; CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator; CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator; + PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator; // Initialize config paths SETTINGS_PATH = CONFIG_PATH + "settings.yml"; @@ -95,6 +97,10 @@ public class InstallationPathConfig { return SCRIPTS_PATH; } + public static String getPipelinePath() { + return PIPELINE_PATH; + } + public static String getSettingsPath() { return SETTINGS_PATH; } diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index b132c0540..9f8d7a7e0 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.Enumeration; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.UUID; import org.springframework.core.io.ClassPathResource; @@ -34,8 +35,16 @@ import stirling.software.common.configuration.InstallationPathConfig; @Slf4j public class GeneralUtils { - private static final List DEFAULT_VALID_SCRIPTS = - List.of("png_to_webp.py", "split_photos.py"); + private static final Set DEFAULT_VALID_SCRIPTS = + Set.of("png_to_webp.py", "split_photos.py"); + private static final Set DEFAULT_VALID_PIPELINE = + Set.of( + "OCR images.json", + "Prepare-pdfs-for-email.json", + "split-rotate-auto-rename.json"); + + private static final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; + private static final String PYTHON_SCRIPTS_DIR = "python"; public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException { String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY"); @@ -446,6 +455,48 @@ public class GeneralUtils { } } + /** + * Extracts the default pipeline configurations from the classpath to the installation path. + * Creates directories if needed and copies default JSON files. + * + *

Existing files will be overwritten atomically (when supported). In case of unsupported + * atomic moves, falls back to non-atomic replace. + * + * @throws IOException if an I/O error occurs during file operations + */ + public static void extractPipeline() throws IOException { + Path pipelineDir = + Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR); + Files.createDirectories(pipelineDir); + + for (String name : DEFAULT_VALID_PIPELINE) { + if (!Paths.get(name).getFileName().toString().equals(name)) { + log.error("Invalid pipeline file name: {}", name); + throw new IllegalArgumentException("Invalid pipeline file name: " + name); + } + Path target = pipelineDir.resolve(name); + ClassPathResource res = + new ClassPathResource( + "static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + } + } + + /** + * Extracts the specified Python script from the classpath to the installation path. Validates + * name and copies file atomically when possible, overwriting existing. + * + *

Existing files will be overwritten atomically (when supported). + * + * @param scriptName the name of the script to extract + * @return the path to the extracted script + * @throws IllegalArgumentException if the script name is invalid or not allowed + * @throws IOException if an I/O error occurs + */ public static Path extractScript(String scriptName) throws IOException { // Validate input if (scriptName == null || scriptName.trim().isEmpty()) { @@ -455,26 +506,71 @@ public class GeneralUtils { throw new IllegalArgumentException( "scriptName must not contain path traversal characters"); } + if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) { + throw new IllegalArgumentException( + "scriptName must not contain path traversal characters"); + } if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) { throw new IllegalArgumentException( "scriptName must be either 'png_to_webp.py' or 'split_photos.py'"); } - Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), "python"); + Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR); Files.createDirectories(scriptsDir); - Path scriptFile = scriptsDir.resolve(scriptName); - if (!Files.exists(scriptFile)) { - ClassPathResource resource = new ClassPathResource("static/python/" + scriptName); - try (InputStream in = resource.getInputStream()) { - Files.copy(in, scriptFile, StandardCopyOption.REPLACE_EXISTING); + Path target = scriptsDir.resolve(scriptName); + ClassPathResource res = + new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + return target; + } + + /** + * Copies a resource from the classpath to a specified target file. + * + * @param resource the ClassPathResource to copy + * @param target the target Path where the resource will be copied + * @throws IOException if an I/O error occurs during the copy operation + */ + private static void copyResourceToFile(ClassPathResource resource, Path target) + throws IOException { + Path dir = target.getParent(); + Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp"); + try (InputStream in = resource.getInputStream()) { + Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + log.warn( + "Atomic move not supported, falling back to non-atomic move for {}", + target, + e); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (FileAlreadyExistsException e) { + log.debug("File already exists at {}, attempting to replace it.", target); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (AccessDeniedException e) { + log.error("Access denied while attempting to copy resource to {}", target, e); + throw e; + } catch (FileSystemException e) { + log.error("File system error occurred while copying resource to {}", target, e); + throw e; + } catch (IOException e) { + log.error("Failed to copy resource to {}", target, e); + throw e; + } finally { + try { + Files.deleteIfExists(tmp); } catch (IOException e) { - log.error("Failed to extract Python script", e); - throw e; + log.warn("Failed to delete temporary file {}", tmp, e); } } - return scriptFile; } public static boolean isVersionHigher(String currentVersion, String compareVersion) { diff --git a/app/core/.gitignore b/app/core/.gitignore index 2b3880de6..5486f9afe 100644 --- a/app/core/.gitignore +++ b/app/core/.gitignore @@ -16,8 +16,7 @@ local.properties version.properties #### Stirling-PDF Files ### -pipeline/watchedFolders/ -pipeline/finishedFolders/ +pipeline/* customFiles/ configs/ watchedFolders/ diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index d242bfeab..2d261c660 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -35,6 +35,7 @@ public class InitialSetup { initEnableCSRFSecurity(); initLegalUrls(); initSetAppVersion(); + GeneralUtils.extractPipeline(); } public void initUUIDKey() throws IOException { diff --git a/app/core/src/main/resources/static/js/search.js b/app/core/src/main/resources/static/js/search.js index 277d722a9..0b8877e62 100644 --- a/app/core/src/main/resources/static/js/search.js +++ b/app/core/src/main/resources/static/js/search.js @@ -82,55 +82,62 @@ document.querySelector("#navbarSearchInput").addEventListener("input", function resultsBox.style.width = window.navItemMaxWidth + "px"; }); +document.addEventListener('DOMContentLoaded', function () { + const searchDropdown = document.getElementById('searchDropdown'); + const searchInput = document.getElementById('navbarSearchInput'); -const searchDropdown = document.getElementById('searchDropdown'); -const searchInput = document.getElementById('navbarSearchInput'); + // Check if elements are missing and skip initialization if necessary + if (!searchDropdown || !searchInput) { + console.warn('Search dropdown or input not found. Skipping initialization.'); + return; + } + const dropdownMenu = searchDropdown.querySelector('.dropdown-menu'); + if (!dropdownMenu) { + console.warn('Dropdown menu not found within the search dropdown. Skipping initialization.'); + return; + } -// Check if elements exist before proceeding -if (searchDropdown && searchInput) { - const dropdownMenu = searchDropdown.querySelector('.dropdown-menu'); + // Create a single dropdown instance + const dropdownInstance = new bootstrap.Dropdown(searchDropdown); - // Create a single dropdown instance - const dropdownInstance = new bootstrap.Dropdown(searchDropdown); - -// Handle click for mobile - searchDropdown.addEventListener('click', function (e) { - e.preventDefault(); - const isOpen = dropdownMenu.classList.contains('show'); - // Close all other open dropdowns - document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => { - if (menu !== dropdownMenu) { - const parentDropdown = menu.closest('.dropdown'); - if (parentDropdown) { - const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]'); - if (parentToggle) { - let instance = bootstrap.Dropdown.getInstance(parentToggle); - if (!instance) { - instance = new bootstrap.Dropdown(parentToggle); - } - instance.hide(); - } - } + // Handle click for mobile + searchDropdown.addEventListener('click', function (e) { + e.preventDefault(); + const isOpen = dropdownMenu.classList.contains('show'); + // Close all other open dropdowns + document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => { + if (menu !== dropdownMenu) { + const parentDropdown = menu.closest('.dropdown'); + if (parentDropdown) { + const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]'); + if (parentToggle) { + let instance = bootstrap.Dropdown.getInstance(parentToggle); + if (!instance) { + instance = new bootstrap.Dropdown(parentToggle); } - }); - if (!isOpen) { - dropdownInstance.show(); - setTimeout(() => searchInput.focus(), 150); - } else { - dropdownInstance.hide(); + instance.hide(); + } } + } }); + if (!isOpen) { + dropdownInstance.show(); + setTimeout(() => searchInput.focus(), 150); + } else { + dropdownInstance.hide(); + } + }); - // Hide dropdown if it's open and user clicks outside - document.addEventListener('click', function(e) { - if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) { - dropdownInstance.hide(); - } - }); + // Hide dropdown if it's open and user clicks outside + document.addEventListener('click', function (e) { + if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) { + dropdownInstance.hide(); + } + }); - // Keep dropdown open if search input is clicked - searchInput.addEventListener('click', function (e) { - e.stopPropagation(); - }); + // Keep dropdown open if search input is clicked + searchInput.addEventListener('click', function (e) { + e.stopPropagation(); + }); -} +}); diff --git a/pipeline/defaultWebUIConfigs/OCR images.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/OCR images.json similarity index 100% rename from pipeline/defaultWebUIConfigs/OCR images.json rename to app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/OCR images.json diff --git a/pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json similarity index 100% rename from pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json rename to app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Prepare-pdfs-for-email.json diff --git a/pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json similarity index 100% rename from pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json rename to app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/split-rotate-auto-rename.json diff --git a/build.gradle b/build.gradle index 1e472e083..897e0ef38 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '1.1.0' + version = '1.1.1' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging'