1
0
mirror of https://github.com/Stirling-Tools/Stirling-PDF.git synced 2024-07-07 09:50:08 +02:00

Merge pull request #570 from Frooodle/test

Pipeline release
This commit is contained in:
Anthony Stirling 2023-12-29 21:51:06 +00:00 committed by GitHub
commit 80a59205fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1705 additions and 806 deletions

4
.gitignore vendored
View File

@ -15,8 +15,8 @@ local.properties
.classpath
.project
version.properties
pipeline/
pipeline/watchedFolders/
pipeline/finishedFolders/
#### Stirling-PDF Files ###
customFiles/
configs/

View File

@ -6,7 +6,8 @@ ARG VERSION_TAG
# Set Environment Variables
ENV DOCKER_ENABLE_SECURITY=false \
HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG
VERSION_TAG=$VERSION_TAG \
JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -XX:MaxRAMPercentage=75"
# PUID=1000 \
# PGID=1000 \
# UMASK=022 \
@ -18,13 +19,14 @@ ENV DOCKER_ENABLE_SECURITY=false \
## mkdir -p $HOME && chown stirlingpdfuser:stirlingpdfgroup $HOME
# Set up necessary directories and permissions
RUN mkdir -p /scripts /usr/share/fonts/opentype/noto /usr/share/tesseract-ocr /configs /customFiles
RUN mkdir -p /scripts /usr/share/fonts/opentype/noto /usr/share/tesseract-ocr /configs /logs /customFiles /pipeline /pipeline/defaultWebUIConfigs /pipeline/watchedFolders /pipeline/finishedFolders
##&& \
## chown -R stirlingpdfuser:stirlingpdfgroup /scripts /usr/share/fonts/opentype/noto /usr/share/tesseract-ocr /configs /customFiles && \
## chown -R stirlingpdfuser:stirlingpdfgroup /usr/share/tesseract-ocr-original
# Copy necessary files
COPY ./scripts/* /scripts/
COPY ./pipeline/ /pipeline/
COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY src/main/resources/static/fonts/*.otf /usr/share/fonts/opentype/noto/
COPY build/libs/*.jar app.jar

View File

@ -17,7 +17,8 @@ RUN apt-get update && \
# Set Environment Variables
ENV DOCKER_ENABLE_SECURITY=false \
HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG
VERSION_TAG=$VERSION_TAG \
JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -XX:MaxRAMPercentage=75"
# PUID=1000 \
# PGID=1000 \
# UMASK=022 \
@ -28,13 +29,14 @@ ENV DOCKER_ENABLE_SECURITY=false \
# mkdir -p $HOME && chown stirlingpdfuser:stirlingpdfgroup $HOME
# Set up necessary directories and permissions
RUN mkdir -p /scripts /usr/share/fonts/opentype/noto /configs /customFiles
RUN mkdir -p /scripts /usr/share/fonts/opentype/noto /configs /customFiles /logs /pipeline /pipeline/defaultWebUIConfigs /pipeline/watchedFolders /pipeline/finishedFolders
# chown -R stirlingpdfuser:stirlingpdfgroup /usr/share/fonts/opentype/noto /configs /customFiles
# Copy necessary files
COPY ./scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY ./scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY ./pipeline/ /pipeline/
COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY src/main/resources/static/fonts/*.otf /usr/share/fonts/opentype/noto/
COPY build/libs/*.jar app.jar

View File

@ -6,7 +6,8 @@ ARG VERSION_TAG
# Set Environment Variables
ENV DOCKER_ENABLE_SECURITY=false \
HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG
VERSION_TAG=$VERSION_TAG \
JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -XX:MaxRAMPercentage=75"
# PUID=1000 \
# PGID=1000 \
# UMASK=022 \
@ -18,12 +19,12 @@ ENV DOCKER_ENABLE_SECURITY=false \
# Set up necessary directories and permissions
#RUN mkdir -p /scripts /configs /customFiles && \
# chown -R stirlingpdfuser:stirlingpdfgroup /scripts /configs /customFiles
# chown -R stirlingpdfuser:stirlingpdfgroup /scripts /configs /customFiles /logs /pipeline /pipeline/defaultWebUIConfigs /pipeline/watchedFolders /pipeline/finishedFolders
RUN mkdir -p /scripts /usr/share/fonts/opentype/noto /configs /customFiles
COPY ./scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY ./scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY ./pipeline/ /pipeline/
COPY build/libs/*.jar app.jar
# Set font cache and permissions

View File

@ -8,7 +8,7 @@ plugins {
}
group = 'stirling.software'
version = '0.17.2'
version = '0.18.0'
sourceCompatibility = '17'
repositories {
@ -48,7 +48,7 @@ launch4j {
errTitle="Encountered error, Do you have Java 17?"
downloadUrl="https://download.oracle.com/java/17/latest/jdk-17_windows-x64_bin.exe"
variables=["BROWSER_OPEN=true"]
variables=["BROWSER_OPEN=true", "ENDPOINTS_GROUPS_TO_REMOVE=CLI"]
jreMinVersion="17"
mutexName="Stirling-PDF"
@ -68,17 +68,17 @@ dependencies {
implementation 'org.springframework:spring-webmvc:6.0.15'
implementation 'org.yaml:snakeyaml:2.1'
implementation 'org.springframework.boot:spring-boot-starter-web:3.1.6'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf:3.1.6'
implementation 'org.springframework.boot:spring-boot-starter-web:3.2.1'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf:3.2.1'
if (System.getenv('DOCKER_ENABLE_SECURITY') != 'false') {
implementation 'org.springframework.boot:spring-boot-starter-security:3.1.6'
implementation 'org.springframework.boot:spring-boot-starter-security:3.2.1'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.2.RELEASE'
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "com.h2database:h2"
}
testImplementation 'org.springframework.boot:spring-boot-starter-test:3.1.6'
testImplementation 'org.springframework.boot:spring-boot-starter-test:3.2.1'
// Batik
implementation 'org.apache.xmlgraphics:batik-all:1.17'

View File

@ -0,0 +1,39 @@
{
"name": "Prepare-pdfs-for-email",
"pipeline": [
{
"operation": "/api/v1/misc/repair",
"parameters": {}
},
{
"operation": "/api/v1/security/sanitize-pdf",
"parameters": {
"removeJavaScript": true,
"removeEmbeddedFiles": false,
"removeMetadata": false,
"removeLinks": false,
"removeFonts": false
}
},
{
"operation": "/api/v1/misc/compress-pdf",
"parameters": {
"optimizeLevel": 2,
"expectedOutputSize": ""
}
},
{
"operation": "/api/v1/general/split-by-size-or-count",
"parameters": {
"splitType": 0,
"splitValue": "15MB"
}
}
],
"_examples": {
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
},
"outputDir": "httpWebRequest",
"outputFileName": "{filename}"
}

View File

@ -0,0 +1,33 @@
{
"name": "split-rotate-auto-rename",
"pipeline": [
{
"operation": "/api/v1/general/split-pdf-by-sections",
"parameters": {
"horizontalDivisions": 2,
"verticalDivisions": 2,
"fileInput": "automated"
}
},
{
"operation": "/api/v1/general/rotate-pdf",
"parameters": {
"angle": 90,
"fileInput": "automated"
}
},
{
"operation": "/api/v1/misc/auto-rename",
"parameters": {
"useFirstTextAsFallback": false,
"fileInput": "automated"
}
}
],
"_examples": {
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
},
"outputDir": "{outputFolder}",
"outputFileName": "{filename}"
}

View File

@ -8,13 +8,14 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
import jakarta.annotation.PostConstruct;
import stirling.software.SPDF.config.ConfigInitializer;
import stirling.software.SPDF.utils.GeneralUtils;
@SpringBootApplication
//@EnableScheduling
@EnableScheduling
public class SPdfApplication {
@Autowired

View File

@ -41,12 +41,16 @@ public class AppConfig {
return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF";
}
@Bean(name = "enableAlphaFunctionality")
public boolean enableAlphaFunctionality() {
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null ? applicationProperties.getSystem().getEnableAlphaFunctionality() : false;
}
@Bean(name = "rateLimit")
public boolean rateLimit() {
String appName = System.getProperty("rateLimit");
if (appName == null)
appName = System.getenv("rateLimit");
System.out.println("rateLimit=" + appName);
return (appName != null) ? Boolean.valueOf(appName) : false;
}

View File

@ -16,33 +16,35 @@ import jakarta.servlet.http.HttpServletResponse;
@Component
public class MetricsFilter extends OncePerRequestFilter {
private final MeterRegistry meterRegistry;
private final MeterRegistry meterRegistry;
@Autowired
public MetricsFilter(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Autowired
public MetricsFilter(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String uri = request.getRequestURI();
//System.out.println("uri="+uri + ", method=" + request.getMethod() );
// Ignore static resources
if (!(uri.startsWith("/js") || uri.startsWith("api-docs") || uri.endsWith("robots.txt") || uri.startsWith("/images") || uri.endsWith(".png") || uri.endsWith(".ico") || uri.endsWith(".css") || uri.endsWith(".svg")|| uri.endsWith(".js") || uri.contains("swagger") || uri.startsWith("/api"))) {
Counter counter = Counter.builder("http.requests")
.tag("uri", uri)
.tag("method", request.getMethod())
.register(meterRegistry);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String uri = request.getRequestURI();
counter.increment();
//System.out.println("Counted");
}
// System.out.println("uri="+uri + ", method=" + request.getMethod() );
// Ignore static resources
if (!(uri.startsWith("/js") || uri.startsWith("/v1/api-docs") || uri.endsWith("robots.txt")
|| uri.startsWith("/images") || uri.startsWith("/images")|| uri.endsWith(".png") || uri.endsWith(".ico") || uri.endsWith(".css") || uri.endsWith(".map")
|| uri.endsWith(".svg") || uri.endsWith(".js") || uri.contains("swagger")
|| uri.startsWith("/api/v1/info") || uri.startsWith("/site.webmanifest") || uri.startsWith("/fonts") || uri.startsWith("/pdfjs") )) {
Counter counter = Counter.builder("http.requests").tag("uri", uri).tag("method", request.getMethod())
.register(meterRegistry);
filterChain.doFilter(request, response);
}
counter.increment();
// System.out.println("Counted");
}
filterChain.doFilter(request, response);
}
}

View File

@ -1,27 +1,42 @@
package stirling.software.SPDF.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import stirling.software.SPDF.model.ApplicationProperties;
@Configuration
public class OpenApiConfig {
@Autowired
ApplicationProperties applicationProperties;
@Bean
public OpenAPI customOpenAPI() {
String version = getClass().getPackage().getImplementationVersion();
if (version == null) {
version = "1.0.0"; // default version if all else fails
}
String version = getClass().getPackage().getImplementationVersion();
if (version == null) {
version = "1.0.0"; // default version if all else fails
}
SecurityScheme apiKeyScheme = new SecurityScheme().type(SecurityScheme.Type.APIKEY).in(SecurityScheme.In.HEADER)
.name("X-API-KEY");
if (!applicationProperties.getSecurity().getEnableLogin()) {
return new OpenAPI().components(new Components())
.info(new Info().title("Stirling PDF API").version(version).description(
"API documentation for all Server-Side processing.\nPlease note some functionality might be UI only and missing from here."));
} else {
return new OpenAPI().components(new Components().addSecuritySchemes("apiKey", apiKeyScheme))
.info(new Info().title("Stirling PDF API").version(version).description(
"API documentation for all Server-Side processing.\nPlease note some functionality might be UI only and missing from here."))
.addSecurityItem(new SecurityRequirement().addList("apiKey"));
}
return new OpenAPI().components(new Components()).info(
new Info().title("Stirling PDF API").version(version).description("API documentation for all Server-Side processing.\nPlease note some functionality might be UI only and missing from here."));
}
}
}

View File

@ -2,27 +2,46 @@ package stirling.software.SPDF.config.security;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
private final LoginAttemptService loginAttemptService;
@Autowired
public CustomAuthenticationFailureHandler(LoginAttemptService loginAttemptService) {
this.loginAttemptService = loginAttemptService;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
String ip = request.getRemoteAddr();
logger.error("Failed login attempt from IP: " + ip);
if (exception.getClass().isAssignableFrom(BadCredentialsException.class)) {
setDefaultFailureUrl("/login?error=badcredentials");
} else if (exception.getClass().isAssignableFrom(LockedException.class)) {
String username = request.getParameter("username");
if(loginAttemptService.loginAttemptCheck(username)) {
setDefaultFailureUrl("/login?error=locked");
} else {
if (exception.getClass().isAssignableFrom(BadCredentialsException.class)) {
setDefaultFailureUrl("/login?error=badcredentials");
} else if (exception.getClass().isAssignableFrom(LockedException.class)) {
setDefaultFailureUrl("/login?error=locked");
}
}
super.onAuthenticationFailure(request, response, exception);
}
}

View File

@ -0,0 +1,44 @@
package stirling.software.SPDF.config.security;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;
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
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
String username = request.getParameter("username");
loginAttemptService.loginSucceeded(username);
// Get the saved request
HttpSession session = request.getSession(false);
SavedRequest savedRequest = session != null ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") : null;
if (savedRequest != null && !RequestUriUtils.isStaticResource(savedRequest.getRedirectUrl())) {
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {
// Redirect to the root URL (considering context path)
getRedirectStrategy().sendRedirect(request, response, "/");
}
//super.onAuthenticationSuccess(request, response, authentication);
}
}

View File

@ -5,6 +5,7 @@ import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@ -22,12 +23,18 @@ public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("No user found with username: " + username));
if (loginAttemptService.isBlocked(username)) {
throw new LockedException("Your account has been locked due to too many failed login attempts.");
}
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),

View File

@ -15,6 +15,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.utils.RequestUriUtils;
@Component
public class FirstLoginFilter extends OncePerRequestFilter {
@ -28,11 +29,7 @@ public class FirstLoginFilter extends OncePerRequestFilter {
String method = request.getMethod();
String requestURI = request.getRequestURI();
// Check if the request is for static resources
boolean isStaticResource = requestURI.startsWith("/css/")
|| requestURI.startsWith("/js/")
|| requestURI.startsWith("/images/")
|| requestURI.startsWith("/public/")
|| requestURI.endsWith(".svg");
boolean isStaticResource = RequestUriUtils.isStaticResource(requestURI);
// If it's a static resource, just continue the filter chain and skip the logic below
if (isStaticResource) {

View File

@ -0,0 +1,65 @@
package stirling.software.SPDF.config.security;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.utils.RequestUriUtils;
public class IPRateLimitingFilter implements Filter {
private final ConcurrentHashMap<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, AtomicInteger> getCounts = new ConcurrentHashMap<>();
private final int maxRequests;
private final int maxGetRequests;
public IPRateLimitingFilter(int maxRequests, int maxGetRequests) {
this.maxRequests = maxRequests;
this.maxGetRequests = maxGetRequests;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (request instanceof HttpServletRequest) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String method = httpRequest.getMethod();
String requestURI = httpRequest.getRequestURI();
// Check if the request is for static resources
boolean isStaticResource = RequestUriUtils.isStaticResource(requestURI);
// If it's a static resource, just continue the filter chain and skip the logic below
if (isStaticResource) {
chain.doFilter(request, response);
return;
}
String clientIp = request.getRemoteAddr();
requestCounts.computeIfAbsent(clientIp, k -> new AtomicInteger(0));
if (!"GET".equalsIgnoreCase(method)) {
if (requestCounts.get(clientIp).incrementAndGet() > maxRequests) {
// Handle limit exceeded (e.g., send error response)
response.getWriter().write("Rate limit exceeded");
return;
}
} else {
if (requestCounts.get(clientIp).incrementAndGet() > maxGetRequests) {
// Handle limit exceeded (e.g., send error response)
response.getWriter().write("GET Rate limit exceeded");
return;
}
}
}
chain.doFilter(request, response);
}
public void resetRequestCounts() {
requestCounts.clear();
getCounts.clear();
}
}

View File

@ -37,8 +37,10 @@ public class InitialSecuritySetup {
initialPassword = "stirling";
userService.saveUser(initialUsername, initialPassword, Role.ADMIN.getRoleId(), true);
}
}
if(!userService.usernameExists(Role.INTERNAL_API_USER.getRoleId())) {
userService.saveUser(Role.INTERNAL_API_USER.getRoleId(), UUID.randomUUID().toString(), Role.INTERNAL_API_USER.getRoleId());
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
}
}

View File

@ -0,0 +1,56 @@
package stirling.software.SPDF.config.security;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.AttemptCounter;
@Service
public class LoginAttemptService {
@Autowired
ApplicationProperties applicationProperties;
private int MAX_ATTEMPTS;
private long ATTEMPT_INCREMENT_TIME;
@PostConstruct
public void init() {
MAX_ATTEMPTS = applicationProperties.getSecurity().getLoginAttemptCount();
ATTEMPT_INCREMENT_TIME = TimeUnit.MINUTES.toMillis(applicationProperties.getSecurity().getLoginResetTimeMinutes());
}
private final ConcurrentHashMap<String, AttemptCounter> attemptsCache = new ConcurrentHashMap<>();
public void loginSucceeded(String key) {
attemptsCache.remove(key);
}
public boolean loginAttemptCheck(String key) {
attemptsCache.compute(key, (k, attemptCounter) -> {
if (attemptCounter == null || attemptCounter.shouldReset(ATTEMPT_INCREMENT_TIME)) {
return new AttemptCounter();
} else {
attemptCounter.increment();
return attemptCounter;
}
});
return attemptsCache.get(key).getAttemptCount() >= MAX_ATTEMPTS;
}
public boolean isBlocked(String key) {
AttemptCounter attemptCounter = attemptsCache.get(key);
if (attemptCounter != null) {
return attemptCounter.getAttemptCount() >= MAX_ATTEMPTS;
}
return false;
}
}

View File

@ -0,0 +1,18 @@
package stirling.software.SPDF.config.security;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class RateLimitResetScheduler {
private final IPRateLimitingFilter rateLimitingFilter;
public RateLimitResetScheduler(IPRateLimitingFilter rateLimitingFilter) {
this.rateLimitingFilter = rateLimitingFilter;
}
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
public void resetRateLimit() {
rateLimitingFilter.resetRequestCounts();
}
}

View File

@ -6,7 +6,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
@ -15,12 +15,13 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import stirling.software.SPDF.repository.JPATokenRepositoryImpl;
@Configuration
@EnableWebSecurity()
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableMethodSecurity
public class SecurityConfiguration {
@Autowired
@ -40,6 +41,11 @@ public class SecurityConfiguration {
@Autowired
private UserAuthenticationFilter userAuthenticationFilter;
@Autowired
private LoginAttemptService loginAttemptService;
@Autowired
private FirstLoginFilter firstLoginFilter;
@ -51,14 +57,18 @@ public class SecurityConfiguration {
if(loginEnabledValue) {
http.csrf(csrf -> csrf.disable());
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
http
.formLogin(formLogin -> formLogin
.loginPage("/login")
.successHandler(new CustomAuthenticationSuccessHandler())
.defaultSuccessUrl("/")
.failureHandler(new CustomAuthenticationFailureHandler())
.failureHandler(new CustomAuthenticationFailureHandler(loginAttemptService))
.permitAll()
)
).requestCache(requestCache -> requestCache
.requestCache(new NullRequestCache())
)
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/login?logout=true")
@ -70,8 +80,19 @@ public class SecurityConfiguration {
.tokenValiditySeconds(1209600) // 2 weeks
)
.authorizeHttpRequests(authz -> authz
.requestMatchers(req -> req.getRequestURI().startsWith("/login") || req.getRequestURI().endsWith(".svg") || req.getRequestURI().startsWith("/register") || req.getRequestURI().startsWith("/error") || req.getRequestURI().startsWith("/images/") || req.getRequestURI().startsWith("/public/") || req.getRequestURI().startsWith("/css/") || req.getRequestURI().startsWith("/js/"))
.permitAll()
.requestMatchers(req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Remove the context path from the URI
String trimmedUri = uri.startsWith(contextPath) ? uri.substring(contextPath.length()) : uri;
return trimmedUri.startsWith("/login") || trimmedUri.endsWith(".svg") ||
trimmedUri.startsWith("/register") || trimmedUri.startsWith("/error") ||
trimmedUri.startsWith("/images/") || trimmedUri.startsWith("/public/") ||
trimmedUri.startsWith("/css/") || trimmedUri.startsWith("/js/");
}
).permitAll()
.anyRequest().authenticated()
)
.userDetailsService(userDetailsService)
@ -84,8 +105,17 @@ public class SecurityConfiguration {
}
return http.build();
}
@Bean
public IPRateLimitingFilter rateLimitingFilter() {
int maxRequestsPerIp = 1000000; // Example limit TODO add config level
return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp);
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {

View File

@ -74,15 +74,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
// If we still don't have any authentication, deny the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
if ("GET".equalsIgnoreCase(method) && !"/login".equals(requestURI)) {
response.sendRedirect("/login"); // redirect to the login page
String contextPath = request.getContextPath();
if ("GET".equalsIgnoreCase(method) && ! (contextPath + "/login").equals(requestURI)) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
return;
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Authentication required. Please provide a X-API-KEY in request header.\nThis is found in Settings -> Account Settings -> API Key\nAlternativly you can disable authentication if this is unexpected");
return;
}
}
}
filterChain.doFilter(request, response);
}
@ -90,15 +92,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
String[] permitAllPatterns = {
"/login",
"/register",
"/error",
"/images/",
"/public/",
"/css/",
"/js/"
contextPath + "/login",
contextPath + "/register",
contextPath + "/error",
contextPath + "/images/",
contextPath + "/public/",
contextPath + "/css/",
contextPath + "/js/",
contextPath + "/pdfjs/",
contextPath + "/site.webmanifest"
};
for (String pattern : permitAllPatterns) {

View File

@ -16,11 +16,13 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import stirling.software.SPDF.controller.api.pipeline.UserServiceInterface;
import stirling.software.SPDF.model.Authority;
import stirling.software.SPDF.model.Role;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.repository.UserRepository;
@Service
public class UserService {
public class UserService implements UserServiceInterface{
@Autowired
private UserRepository userRepository;
@ -136,6 +138,11 @@ public class UserService {
public void deleteUser(String username) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isPresent()) {
for (Authority authority : userOpt.get().getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
return;
}
}
userRepository.delete(userOpt.get());
}
}

View File

@ -29,12 +29,12 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Tag(name = "General", description = "General APIs")
public class SplitPdfBySectionsController {
@PostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
@Operation(summary = "Split PDF pages into smaller sections", description = "Split each page of a PDF into smaller sections based on the user's choice (halves, thirds, quarters, etc.), both vertically and horizontally. Input: PDF, Split Parameters. Output: ZIP containing split documents.")
@Operation(summary = "Split PDF pages into smaller sections", description = "Split each page of a PDF into smaller sections based on the user's choice (halves, thirds, quarters, etc.), both vertically and horizontally. Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();

View File

@ -26,13 +26,13 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Tag(name = "General", description = "General APIs")
public class SplitPdfBySizeController {
@PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@Operation(summary = "Auto split PDF pages into separate documents based on size or count", description = "split PDF into multiple paged documents based on size/count, ie if 20 pages and split into 5, it does 5 documents each 4 pages\r\n"
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF Output:ZIP Type:SIMO")
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<ByteArrayOutputStream>();

View File

@ -23,6 +23,7 @@ import org.springframework.web.servlet.view.RedirectView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.model.Role;
import stirling.software.SPDF.model.User;
@Controller
@ -32,6 +33,7 @@ public class UserController {
@Autowired
private UserService userService;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
public String register(@RequestParam String username, @RequestParam String password, Model model) {
if(userService.usernameExists(username)) {
@ -43,6 +45,7 @@ public class UserController {
return "redirect:/login?registered=true";
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-username-and-password")
public RedirectView changeUsernameAndPassword(Principal principal,
@RequestParam String currentPassword,
@ -85,7 +88,7 @@ public class UserController {
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-username")
public RedirectView changeUsername(Principal principal,
@RequestParam String currentPassword,
@ -122,7 +125,8 @@ public class UserController {
return new RedirectView("/login?messageType=credsUpdated");
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password")
public RedirectView changePassword(Principal principal,
@RequestParam String currentPassword,
@ -154,7 +158,7 @@ public class UserController {
return new RedirectView("/login?messageType=credsUpdated");
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/updateUserSettings")
public String updateUserSettings(HttpServletRequest request, Principal principal) {
Map<String, String[]> paramMap = request.getParameterMap();
@ -182,6 +186,18 @@ public class UserController {
if(userService.usernameExists(username)) {
return new RedirectView("/addUsers?messageType=usernameExists");
}
try {
// Validate the role
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request
return new RedirectView("/addUsers?messageType=invalidRole");
}
} catch (IllegalArgumentException e) {
// If the role ID is not valid, redirect with an error message
return new RedirectView("/addUsers?messageType=invalidRole");
}
userService.saveUser(username, password, role, forceChange);
return new RedirectView("/addUsers"); // Redirect to account page after adding the user
}
@ -203,6 +219,7 @@ public class UserController {
return "redirect:/addUsers";
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/get-api-key")
public ResponseEntity<String> getApiKey(Principal principal) {
if (principal == null) {
@ -216,6 +233,7 @@ public class UserController {
return ResponseEntity.ok(apiKey);
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/update-api-key")
public ResponseEntity<String> updateApiKey(Principal principal) {
if (principal == null) {

View File

@ -1,130 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.SPDF.model.api.GeneralFile;
import stirling.software.SPDF.utils.FileToPdf;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
public class ConvertEpubToPdf {
//TODO
@PostMapping(consumes = "multipart/form-data", value = "/epub-to-single-pdf")
@Hidden
@Operation(
summary = "Convert an EPUB file to a single PDF",
description = "This endpoint takes an EPUB file input and converts it to a single PDF."
)
public ResponseEntity<byte[]> epubToSinglePdf(
@ModelAttribute GeneralFile request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
if (fileInput == null) {
throw new IllegalArgumentException("Please provide an EPUB file for conversion.");
}
String originalFilename = fileInput.getOriginalFilename();
if (originalFilename == null || !originalFilename.endsWith(".epub")) {
throw new IllegalArgumentException("File must be in .epub format.");
}
Map<String, byte[]> epubContents = extractEpubContent(fileInput);
List<String> htmlFilesOrder = getHtmlFilesOrderFromOpf(epubContents);
List<byte[]> individualPdfs = new ArrayList<>();
for (String htmlFile : htmlFilesOrder) {
byte[] htmlContent = epubContents.get(htmlFile);
byte[] pdfBytes = FileToPdf.convertHtmlToPdf(htmlContent, htmlFile.replace(".html", ".pdf"));
individualPdfs.add(pdfBytes);
}
// Pseudo-code to merge individual PDFs into one.
byte[] mergedPdfBytes = mergeMultiplePdfsIntoOne(individualPdfs);
return WebResponseUtils.bytesToWebResponse(mergedPdfBytes, originalFilename.replace(".epub", ".pdf"));
}
// Assuming a pseudo-code function that merges multiple PDFs into one.
private byte[] mergeMultiplePdfsIntoOne(List<byte[]> individualPdfs) {
// You can use a library such as PDFBox to perform the merging here.
// Return the byte[] of the merged PDF.
return null;
}
private Map<String, byte[]> extractEpubContent(MultipartFile fileInput) throws IOException {
Map<String, byte[]> contentMap = new HashMap<>();
try (ZipInputStream zis = new ZipInputStream(fileInput.getInputStream())) {
ZipEntry zipEntry = zis.getNextEntry();
while (zipEntry != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int read = 0;
while ((read = zis.read(buffer)) != -1) {
baos.write(buffer, 0, read);
}
contentMap.put(zipEntry.getName(), baos.toByteArray());
zipEntry = zis.getNextEntry();
}
}
return contentMap;
}
private List<String> getHtmlFilesOrderFromOpf(Map<String, byte[]> epubContents) throws Exception {
String opfContent = new String(epubContents.get("OEBPS/content.opf")); // Adjusting for given path
DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
InputSource is = new InputSource(new StringReader(opfContent));
Document doc = dBuilder.parse(is);
NodeList itemRefs = doc.getElementsByTagName("itemref");
List<String> htmlFilesOrder = new ArrayList<>();
for (int i = 0; i < itemRefs.getLength(); i++) {
Element itemRef = (Element) itemRefs.item(i);
String idref = itemRef.getAttribute("idref");
NodeList items = doc.getElementsByTagName("item");
for (int j = 0; j < items.getLength(); j++) {
Element item = (Element) items.item(j);
if (idref.equals(item.getAttribute("id"))) {
htmlFilesOrder.add(item.getAttribute("href")); // Fetching the actual href
break;
}
}
}
return htmlFilesOrder;
}
}

View File

@ -28,7 +28,7 @@ public class ConvertWebsiteToPDF {
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description = "This endpoint fetches content from a URL and converts it to a PDF format."
description = "This endpoint fetches content from a URL and converts it to a PDF format. Input:N/A Output:PDF Type:SISO"
)
public ResponseEntity<byte[]> urlToPdf(@ModelAttribute UrlToPdfRequest request) throws IOException, InterruptedException {
String URL = request.getUrlInput();

View File

@ -28,12 +28,12 @@ import stirling.software.SPDF.model.api.extract.PDFFilePage;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "General", description = "General APIs")
@Tag(name = "Convert", description = "Convert APIs")
public class ExtractController {
private static final Logger logger = LoggerFactory.getLogger(CropController.class);
@PostMapping(value = "/pdf-to-csv", consumes = "multipart/form-data")
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@Operation(summary = "Extracts a PDF document to csv", description = "This operation takes an input PDF file and returns CSV file of whole page. Input:PDF Output:CSV Type:SISO")
public ResponseEntity<String> PdfToCsv(@ModelAttribute PDFFilePage form)
throws Exception {

View File

@ -43,7 +43,7 @@ public class AutoSplitPdfController {
private static final String QR_CONTENT = "https://github.com/Frooodle/Stirling-PDF";
@PostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
@Operation(summary = "Auto split PDF pages into separate documents", description = "This endpoint accepts a PDF file, scans each page for a specific QR code, and splits the document at the QR code boundaries. The output is a zip file containing each separate PDF document. Input:PDF Output:ZIP Type:SISO")
@Operation(summary = "Auto split PDF pages into separate documents", description = "This endpoint accepts a PDF file, scans each page for a specific QR code, and splits the document at the QR code boundaries. The output is a zip file containing each separate PDF document. Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request) throws IOException {
MultipartFile file = request.getFileInput();
boolean duplexMode = request.isDuplexMode();

View File

@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.SPDF.model.api.PDFFile;
import stirling.software.SPDF.utils.WebResponseUtils;
@ -25,6 +26,7 @@ public class ShowJavascript {
private static final Logger logger = LoggerFactory.getLogger(ShowJavascript.class);
@PostMapping(consumes = "multipart/form-data", value = "/show-javascript")
@Operation(summary = "Grabs all JS from a PDF and returns a single JS file with all code", description = "desc. Input:PDF Output:JS Type:SISO")
public ResponseEntity<byte[]> extractHeader(@ModelAttribute PDFFile request) throws Exception {
MultipartFile inputFile = request.getFileInput();
String script = "";

View File

@ -0,0 +1,119 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletContext;
import stirling.software.SPDF.model.ApiEndpoint;
import stirling.software.SPDF.model.Role;
@Service
public class ApiDocService {
private final Map<String, ApiEndpoint> apiDocumentation = new HashMap<>();
@Autowired
private ServletContext servletContext;
private String getApiDocsUrl() {
String contextPath = servletContext.getContextPath();
String port = System.getProperty("local.server.port");
if(port == null || port.length() == 0) {
port="8080";
}
return "http://localhost:"+ port + contextPath + "/v1/api-docs";
}
@Autowired(required=false)
private UserServiceInterface userService;
private String getApiKeyForUser() {
if(userService == null)
return "";
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
JsonNode apiDocsJsonRootNode;
//@EventListener(ApplicationReadyEvent.class)
private synchronized void loadApiDocumentation() {
try {
HttpHeaders headers = new HttpHeaders();
String apiKey = getApiKeyForUser();
if (!apiKey.isEmpty()) {
headers.set("X-API-KEY", apiKey);
}
HttpEntity<String> entity = new HttpEntity<>(headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.exchange(getApiDocsUrl(), HttpMethod.GET, entity, String.class);
String apiDocsJson = response.getBody();
ObjectMapper mapper = new ObjectMapper();
apiDocsJsonRootNode = mapper.readTree(apiDocsJson);
JsonNode paths = apiDocsJsonRootNode.path("paths");
paths.fields().forEachRemaining(entry -> {
String path = entry.getKey();
JsonNode pathNode = entry.getValue();
if (pathNode.has("post")) {
JsonNode postNode = pathNode.get("post");
ApiEndpoint endpoint = new ApiEndpoint(path, postNode);
apiDocumentation.put(path, endpoint);
}
});
} catch (Exception e) {
// Handle exceptions
e.printStackTrace();
}
}
public boolean isValidOperation(String operationName, Map<String, Object> parameters) {
if(apiDocumentation.size() == 0) {
loadApiDocumentation();
}
if (!apiDocumentation.containsKey(operationName)) {
return false;
}
ApiEndpoint endpoint = apiDocumentation.get(operationName);
return endpoint.areParametersValid(parameters);
}
public boolean isMultiInput(String operationName) {
if(apiDocsJsonRootNode == null || apiDocumentation.size() == 0) {
loadApiDocumentation();
}
if (!apiDocumentation.containsKey(operationName)) {
return false;
}
ApiEndpoint endpoint = apiDocumentation.get(operationName);
String description = endpoint.getDescription();
Pattern pattern = Pattern.compile("Type:(\\w+)");
Matcher matcher = pattern.matcher(description);
if (matcher.find()) {
String type = matcher.group(1);
return type.startsWith("MI");
}
return false;
}
}
// Model class for API Endpoint

View File

@ -1,56 +1,31 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.api.HandleDataRequest;
import stirling.software.SPDF.utils.WebResponseUtils;
@ -60,373 +35,39 @@ import stirling.software.SPDF.utils.WebResponseUtils;
public class PipelineController {
private static final Logger logger = LoggerFactory.getLogger(PipelineController.class);
@Autowired
private ObjectMapper objectMapper;
final String jsonFileName = "pipelineConfig.json";
final String watchedFoldersDir = "./pipeline/watchedFolders/";
final String finishedFoldersDir = "./pipeline/finishedFolders/";
@Autowired
PipelineProcessor processor;
@Scheduled(fixedRate = 25000)
public void scanFolders() {
logger.info("Scanning folders...");
Path watchedFolderPath = Paths.get(watchedFoldersDir);
if (!Files.exists(watchedFolderPath)) {
try {
Files.createDirectories(watchedFolderPath);
logger.info("Created directory: {}", watchedFolderPath);
} catch (IOException e) {
logger.error("Error creating directory: {}", watchedFolderPath, e);
return;
}
}
try (Stream<Path> paths = Files.walk(watchedFolderPath)) {
paths.filter(Files::isDirectory).forEach(t -> {
try {
if (!t.equals(watchedFolderPath) && !t.endsWith("processing")) {
handleDirectory(t);
}
} catch (Exception e) {
logger.error("Error handling directory: {}", t, e);
}
});
} catch (Exception e) {
logger.error("Error walking through directory: {}", watchedFolderPath, e);
}
}
@Autowired
ApplicationProperties applicationProperties;
private void handleDirectory(Path dir) throws Exception {
logger.info("Handling directory: {}", dir);
Path jsonFile = dir.resolve(jsonFileName);
Path processingDir = dir.resolve("processing"); // Directory to move files during processing
if (!Files.exists(processingDir)) {
Files.createDirectory(processingDir);
logger.info("Created processing directory: {}", processingDir);
}
if (Files.exists(jsonFile)) {
// Read JSON file
String jsonString;
try {
jsonString = new String(Files.readAllBytes(jsonFile));
logger.info("Read JSON file: {}", jsonFile);
} catch (IOException e) {
logger.error("Error reading JSON file: {}", jsonFile, e);
return;
}
// Decode JSON to PipelineConfig
PipelineConfig config;
try {
config = objectMapper.readValue(jsonString, PipelineConfig.class);
// Assuming your PipelineConfig class has getters for all necessary fields, you
// can perform checks here
if (config.getOperations() == null || config.getOutputDir() == null || config.getName() == null) {
throw new IOException("Invalid JSON format");
}
} catch (IOException e) {
logger.error("Error parsing PipelineConfig: {}", jsonString, e);
return;
}
// For each operation in the pipeline
for (PipelineOperation operation : config.getOperations()) {
// Collect all files based on fileInput
File[] files;
String fileInput = (String) operation.getParameters().get("fileInput");
if ("automated".equals(fileInput)) {
// If fileInput is "automated", process all files in the directory
try (Stream<Path> paths = Files.list(dir)) {
files = paths
.filter(path -> !Files.isDirectory(path)) // exclude directories
.filter(path -> !path.equals(jsonFile)) // exclude jsonFile
.map(Path::toFile)
.toArray(File[]::new);
} catch (IOException e) {
e.printStackTrace();
return;
}
} else {
// If fileInput contains a path, process only this file
files = new File[] { new File(fileInput) };
}
// Prepare the files for processing
List<File> filesToProcess = new ArrayList<>();
for (File file : files) {
logger.info(file.getName());
logger.info("{} to {}",file.toPath(), processingDir.resolve(file.getName()));
Files.move(file.toPath(), processingDir.resolve(file.getName()));
filesToProcess.add(processingDir.resolve(file.getName()).toFile());
}
// Process the files
try {
List<Resource> resources = handleFiles(filesToProcess.toArray(new File[0]), jsonString);
if(resources == null) {
return;
}
// Move resultant files and rename them as per config in JSON file
for (Resource resource : resources) {
String resourceName = resource.getFilename();
String baseName = resourceName.substring(0, resourceName.lastIndexOf("."));
String extension = resourceName.substring(resourceName.lastIndexOf(".")+1);
String outputFileName = config.getOutputPattern().replace("{filename}", baseName);
outputFileName = outputFileName.replace("{pipelineName}", config.getName());
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
outputFileName = outputFileName.replace("{date}", LocalDate.now().format(dateFormatter));
DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HHmmss");
outputFileName = outputFileName.replace("{time}", LocalTime.now().format(timeFormatter));
outputFileName += "." + extension;
// {filename} {folder} {date} {tmime} {pipeline}
String outputDir = config.getOutputDir();
String outputFolder = applicationProperties.getAutoPipeline().getOutputFolder();
if (outputFolder == null || outputFolder.isEmpty()) {
// If the environment variable is not set, use the default value
outputFolder = finishedFoldersDir;
}
logger.info("outputDir 0={}", outputDir);
// Replace the placeholders in the outputDir string
outputDir = outputDir.replace("{outputFolder}", outputFolder);
outputDir = outputDir.replace("{folderName}", dir.toString());
logger.info("outputDir 1={}", outputDir);
outputDir = outputDir.replace("\\watchedFolders", "");
outputDir = outputDir.replace("//watchedFolders", "");
outputDir = outputDir.replace("\\\\watchedFolders", "");
outputDir = outputDir.replace("/watchedFolders", "");
Path outputPath;
logger.info("outputDir 2={}", outputDir);
if (Paths.get(outputDir).isAbsolute()) {
// If it's an absolute path, use it directly
outputPath = Paths.get(outputDir);
} else {
// If it's a relative path, make it relative to the current working directory
outputPath = Paths.get(".", outputDir);
}
logger.info("outputPath={}", outputPath);
if (!Files.exists(outputPath)) {
try {
Files.createDirectories(outputPath);
logger.info("Created directory: {}", outputPath);
} catch (IOException e) {
logger.error("Error creating directory: {}", outputPath, e);
return;
}
}
logger.info("outputPath {}", outputPath);
logger.info("outputPath.resolve(outputFileName).toString() {}", outputPath.resolve(outputFileName).toString());
File newFile = new File(outputPath.resolve(outputFileName).toString());
OutputStream os = new FileOutputStream(newFile);
os.write(((ByteArrayResource)resource).getByteArray());
os.close();
logger.info("made {}", outputPath.resolve(outputFileName));
}
// If successful, delete the original files
for (File file : filesToProcess) {
Files.deleteIfExists(processingDir.resolve(file.getName()));
}
} catch (Exception e) {
// If an error occurs, move the original files back
for (File file : filesToProcess) {
Files.move(processingDir.resolve(file.getName()), file.toPath());
}
throw e;
}
}
}
}
List<Resource> processFiles(List<Resource> outputFiles, String jsonString) throws Exception {
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(jsonString);
JsonNode pipelineNode = jsonNode.get("pipeline");
logger.info("Running pipelineNode: {}", pipelineNode);
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
PrintStream logPrintStream = new PrintStream(logStream);
boolean hasErrors = false;
for (JsonNode operationNode : pipelineNode) {
String operation = operationNode.get("operation").asText();
logger.info("Running operation: {}", operation);
JsonNode parametersNode = operationNode.get("parameters");
String inputFileExtension = "";
if (operationNode.has("inputFileType")) {
inputFileExtension = operationNode.get("inputFileType").asText();
} else {
inputFileExtension = ".pdf";
}
List<Resource> newOutputFiles = new ArrayList<>();
boolean hasInputFileType = false;
for (Resource file : outputFiles) {
if (file.getFilename().endsWith(inputFileExtension)) {
hasInputFileType = true;
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", file);
Iterator<Map.Entry<String, JsonNode>> parameters = parametersNode.fields();
while (parameters.hasNext()) {
Map.Entry<String, JsonNode> parameter = parameters.next();
body.add(parameter.getKey(), parameter.getValue().asText());
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/" + operation;
ResponseEntity<byte[]> response = restTemplate.exchange(url, HttpMethod.POST, entity, byte[].class);
// If the operation is filter and the response body is null or empty, skip this file
if (operation.startsWith("filter-") && (response.getBody() == null || response.getBody().length == 0)) {
logger.info("Skipping file due to failing {}", operation);
continue;
}
if (!response.getStatusCode().equals(HttpStatus.OK)) {
logPrintStream.println("Error: " + response.getBody());
hasErrors = true;
continue;
}
// Define filename
String filename;
if ("auto-rename".equals(operation)) {
// If the operation is "auto-rename", generate a new filename.
// This is a simple example of generating a filename using current timestamp.
// Modify as per your needs.
filename = "file_" + System.currentTimeMillis();
} else {
// Otherwise, keep the original filename.
filename = file.getFilename();
}
// Check if the response body is a zip file
if (isZip(response.getBody())) {
// Unzip the file and add all the files to the new output files
newOutputFiles.addAll(unzip(response.getBody()));
} else {
Resource outputResource = new ByteArrayResource(response.getBody()) {
@Override
public String getFilename() {
return filename;
}
};
newOutputFiles.add(outputResource);
}
}
if (!hasInputFileType) {
logPrintStream.println(
"No files with extension " + inputFileExtension + " found for operation " + operation);
hasErrors = true;
}
outputFiles = newOutputFiles;
}
logPrintStream.close();
}
if (hasErrors) {
logger.error("Errors occurred during processing. Log: {}", logStream.toString());
}
return outputFiles;
}
List<Resource> handleFiles(File[] files, String jsonString) throws Exception {
if(files == null || files.length == 0) {
logger.info("No files");
return null;
}
logger.info("Handling files: {} files, with JSON string of length: {}", files.length, jsonString.length());
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(jsonString);
JsonNode pipelineNode = jsonNode.get("pipeline");
boolean hasErrors = false;
List<Resource> outputFiles = new ArrayList<>();
for (File file : files) {
Path path = Paths.get(file.getAbsolutePath());
System.out.println("Reading file: " + path); // debug statement
if (Files.exists(path)) {
Resource fileResource = new ByteArrayResource(Files.readAllBytes(path)) {
@Override
public String getFilename() {
return file.getName();
}
};
outputFiles.add(fileResource);
} else {
System.out.println("File not found: " + path); // debug statement
}
}
logger.info("Files successfully loaded. Starting processing...");
return processFiles(outputFiles, jsonString);
}
List<Resource> handleFiles(MultipartFile[] files, String jsonString) throws Exception {
if(files == null || files.length == 0) {
logger.info("No files");
return null;
}
logger.info("Handling files: {} files, with JSON string of length: {}", files.length, jsonString.length());
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(jsonString);
JsonNode pipelineNode = jsonNode.get("pipeline");
boolean hasErrors = false;
List<Resource> outputFiles = new ArrayList<>();
for (MultipartFile file : files) {
Resource fileResource = new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return file.getOriginalFilename();
}
};
outputFiles.add(fileResource);
}
logger.info("Files successfully loaded. Starting processing...");
return processFiles(outputFiles, jsonString);
}
@Autowired
private ObjectMapper objectMapper;
@PostMapping("/handleData")
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request) {
MultipartFile[] files = request.getFileInputs();
String jsonString = request.getJsonString();
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request) throws JsonMappingException, JsonProcessingException {
if (!Boolean.TRUE.equals(applicationProperties.getSystem().getEnableAlphaFunctionality())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
MultipartFile[] files = request.getFileInput();
String jsonString = request.getJson();
if (files == null) {
return null;
}
PipelineConfig config = objectMapper.readValue(jsonString, PipelineConfig.class);
logger.info("Received POST request to /handleData with {} files", files.length);
try {
List<Resource> outputFiles = handleFiles(files, jsonString);
List<Resource> inputFiles = processor.generateInputFiles(files);
if(inputFiles == null || inputFiles.size() == 0) {
return null;
}
List<Resource> outputFiles = processor.runPipelineAgainstFiles(inputFiles, config);
if (outputFiles != null && outputFiles.size() == 1) {
// If there is only one file, return it directly
Resource singleFile = outputFiles.get(0);
@ -473,52 +114,4 @@ public class PipelineController {
}
}
private boolean isZip(byte[] data) {
if (data == null || data.length < 4) {
return false;
}
// Check the first four bytes of the data against the standard zip magic number
return data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04;
}
private List<Resource> unzip(byte[] data) throws IOException {
logger.info("Unzipping data of length: {}", data.length);
List<Resource> unzippedFiles = new ArrayList<>();
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ZipInputStream zis = new ZipInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int count;
while ((count = zis.read(buffer)) != -1) {
baos.write(buffer, 0, count);
}
final String filename = entry.getName();
Resource fileResource = new ByteArrayResource(baos.toByteArray()) {
@Override
public String getFilename() {
return filename;
}
};
// If the unzipped file is a zip file, unzip it
if (isZip(baos.toByteArray())) {
logger.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(baos.toByteArray()));
} else {
unzippedFiles.add(fileResource);
}
}
}
logger.info("Unzipping completed. {} files were unzipped.", unzippedFiles.size());
return unzippedFiles;
}
}

View File

@ -0,0 +1,258 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
@Service
public class PipelineDirectoryProcessor {
private static final Logger logger = LoggerFactory.getLogger(PipelineDirectoryProcessor.class);
@Autowired
private ObjectMapper objectMapper;
@Autowired
private ApiDocService apiDocService;
@Autowired
private ApplicationProperties applicationProperties;
final String watchedFoldersDir = "./pipeline/watchedFolders/";
final String finishedFoldersDir = "./pipeline/finishedFolders/";
@Autowired
PipelineProcessor processor;
@Scheduled(fixedRate = 60000)
public void scanFolders() {
if (!Boolean.TRUE.equals(applicationProperties.getSystem().getEnableAlphaFunctionality())) {
return;
}
Path watchedFolderPath = Paths.get(watchedFoldersDir);
if (!Files.exists(watchedFolderPath)) {
try {
Files.createDirectories(watchedFolderPath);
logger.info("Created directory: {}", watchedFolderPath);
} catch (IOException e) {
logger.error("Error creating directory: {}", watchedFolderPath, e);
return;
}
}
try (Stream<Path> paths = Files.walk(watchedFolderPath)) {
paths.filter(Files::isDirectory).forEach(t -> {
try {
if (!t.equals(watchedFolderPath) && !t.endsWith("processing")) {
handleDirectory(t);
}
} catch (Exception e) {
logger.error("Error handling directory: {}", t, e);
}
});
} catch (Exception e) {
logger.error("Error walking through directory: {}", watchedFolderPath, e);
}
}
public void handleDirectory(Path dir) throws IOException {
logger.info("Handling directory: {}", dir);
Path processingDir = createProcessingDirectory(dir);
Optional<Path> jsonFileOptional = findJsonFile(dir);
if (!jsonFileOptional.isPresent()) {
logger.warn("No .JSON settings file found. No processing will happen for dir {}.", dir);
return;
}
Path jsonFile = jsonFileOptional.get();
PipelineConfig config = readAndParseJson(jsonFile);
processPipelineOperations(dir, processingDir, jsonFile, config);
}
private Path createProcessingDirectory(Path dir) throws IOException {
Path processingDir = dir.resolve("processing");
if (!Files.exists(processingDir)) {
Files.createDirectory(processingDir);
logger.info("Created processing directory: {}", processingDir);
}
return processingDir;
}
private Optional<Path> findJsonFile(Path dir) throws IOException {
try (Stream<Path> paths = Files.list(dir)) {
return paths.filter(file -> file.toString().endsWith(".json")).findFirst();
}
}
private PipelineConfig readAndParseJson(Path jsonFile) throws IOException {
String jsonString = new String(Files.readAllBytes(jsonFile), StandardCharsets.UTF_8);
logger.debug("Reading JSON file: {}", jsonFile);
return objectMapper.readValue(jsonString, PipelineConfig.class);
}
private void processPipelineOperations(Path dir, Path processingDir, Path jsonFile, PipelineConfig config) throws IOException {
for (PipelineOperation operation : config.getOperations()) {
validateOperation(operation);
File[] files = collectFilesForProcessing(dir, jsonFile, operation);
if(files == null || files.length == 0) {
logger.debug("No files detected for {} ", dir);
return;
}
List<File> filesToProcess = prepareFilesForProcessing(files, processingDir);
runPipelineAgainstFiles(filesToProcess, config, dir, processingDir);
}
}
private void validateOperation(PipelineOperation operation) throws IOException {
if (!apiDocService.isValidOperation(operation.getOperation(), operation.getParameters())) {
throw new IOException("Invalid operation: " + operation.getOperation());
}
}
private File[] collectFilesForProcessing(Path dir, Path jsonFile, PipelineOperation operation) throws IOException {
try (Stream<Path> paths = Files.list(dir)) {
if ("automated".equals(operation.getParameters().get("fileInput"))) {
return paths.filter(path -> !Files.isDirectory(path) && !path.equals(jsonFile))
.map(Path::toFile)
.toArray(File[]::new);
} else {
String fileInput = (String) operation.getParameters().get("fileInput");
return new File[]{new File(fileInput)};
}
}
}
private List<File> prepareFilesForProcessing(File[] files, Path processingDir) throws IOException {
List<File> filesToProcess = new ArrayList<>();
for (File file : files) {
Path targetPath = resolveUniqueFilePath(processingDir, file.getName());
Files.move(file.toPath(), targetPath);
filesToProcess.add(targetPath.toFile());
}
return filesToProcess;
}
private Path resolveUniqueFilePath(Path directory, String originalFileName) {
Path filePath = directory.resolve(originalFileName);
int counter = 1;
while (Files.exists(filePath)) {
String newName = appendSuffixToFileName(originalFileName, "(" + counter + ")");
filePath = directory.resolve(newName);
counter++;
}
return filePath;
}
private String appendSuffixToFileName(String originalFileName, String suffix) {
int dotIndex = originalFileName.lastIndexOf('.');
if (dotIndex == -1) {
return originalFileName + suffix;
} else {
return originalFileName.substring(0, dotIndex) + suffix + originalFileName.substring(dotIndex);
}
}
private void runPipelineAgainstFiles(List<File> filesToProcess, PipelineConfig config, Path dir, Path processingDir) throws IOException {
try {
List<Resource> inputFiles = processor.generateInputFiles(filesToProcess.toArray(new File[0]));
if(inputFiles == null || inputFiles.size() == 0) {
return;
}
List<Resource> outputFiles = processor.runPipelineAgainstFiles(inputFiles, config);
if (outputFiles == null) return;
moveAndRenameFiles(outputFiles, config, dir);
deleteOriginalFiles(filesToProcess, processingDir);
} catch (Exception e) {
logger.error("error during processing", e);
moveFilesBack(filesToProcess, processingDir);
}
}
private void moveAndRenameFiles(List<Resource> resources, PipelineConfig config, Path dir) throws IOException {
for (Resource resource : resources) {
String outputFileName = createOutputFileName(resource, config);
Path outputPath = determineOutputPath(config, dir);
if (!Files.exists(outputPath)) {
Files.createDirectories(outputPath);
logger.info("Created directory: {}", outputPath);
}
Path outputFile = outputPath.resolve(outputFileName);
try (OutputStream os = new FileOutputStream(outputFile.toFile())) {
os.write(((ByteArrayResource) resource).getByteArray());
}
logger.info("File moved and renamed to {}", outputFile);
}
}
private String createOutputFileName(Resource resource, PipelineConfig config) {
String resourceName = resource.getFilename();
String baseName = resourceName.substring(0, resourceName.lastIndexOf('.'));
String extension = resourceName.substring(resourceName.lastIndexOf('.') + 1);
String outputFileName = config.getOutputPattern()
.replace("{filename}", baseName)
.replace("{pipelineName}", config.getName())
.replace("{date}", LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
.replace("{time}", LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss")))
+ "." + extension;
return outputFileName;
}
private Path determineOutputPath(PipelineConfig config, Path dir) {
String outputDir = config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString())
.replaceAll("\\\\?watchedFolders", "");
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
}
private void deleteOriginalFiles(List<File> filesToProcess, Path processingDir) throws IOException {
for (File file : filesToProcess) {
Files.deleteIfExists(processingDir.resolve(file.getName()));
logger.info("Deleted original file: {}", file.getName());
}
}
private void moveFilesBack(List<File> filesToProcess, Path processingDir) {
for (File file : filesToProcess) {
try {
Files.move(processingDir.resolve(file.getName()), file.toPath());
logger.info("Moved file back to original location: {} , {}",file.toPath(), file.getName());
} catch (IOException e) {
logger.error("Error moving file back to original location: {}", file.getName(), e);
}
}
}
}

View File

@ -0,0 +1,328 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.ServletContext;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.Role;
@Service
public class PipelineProcessor {
private static final Logger logger = LoggerFactory.getLogger(PipelineProcessor.class);
@Autowired
private ApiDocService apiDocService;
@Autowired
private UserServiceInterface userService;
@Autowired
private ServletContext servletContext;
private String getApiKeyForUser() {
if (userService == null)
return "";
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
private String getBaseUrl() {
String contextPath = servletContext.getContextPath();
return "http://localhost:8080" + contextPath + "/";
}
List<Resource> runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config) throws Exception {
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
PrintStream logPrintStream = new PrintStream(logStream);
boolean hasErrors = false;
for (PipelineOperation pipelineOperation : config.getOperations()) {
String operation = pipelineOperation.getOperation();
boolean isMultiInputOperation = apiDocService.isMultiInput(operation);
logger.info("Running operation: {} isMultiInputOperation {}", operation, isMultiInputOperation);
Map<String, Object> parameters = pipelineOperation.getParameters();
String inputFileExtension = "";
//TODO
//if (operationNode.has("inputFileType")) {
// inputFileExtension = operationNode.get("inputFileType").asText();
//} else {
inputFileExtension = ".pdf";
//}
final String finalInputFileExtension = inputFileExtension;
String url = getBaseUrl() + operation;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
for (Resource file : outputFiles) {
boolean hasInputFileType = false;
if (file.getFilename().endsWith(inputFileExtension)) {
hasInputFileType = true;
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", file);
for(Entry<String, Object> entry : parameters.entrySet()) {
body.add(entry.getKey(), entry.getValue());
}
ResponseEntity<byte[]> response = sendWebRequest(url, body);
// If the operation is filter and the response body is null or empty, skip this
// file
if (operation.startsWith("filter-")
&& (response.getBody() == null || response.getBody().length == 0)) {
logger.info("Skipping file due to failing {}", operation);
continue;
}
if (!response.getStatusCode().equals(HttpStatus.OK)) {
logPrintStream.println("Error: " + response.getBody());
hasErrors = true;
continue;
}
processOutputFiles(operation, file.getFilename(), response, newOutputFiles);
}
if (!hasInputFileType) {
logPrintStream.println(
"No files with extension " + inputFileExtension + " found for operation " + operation);
hasErrors = true;
}
outputFiles = newOutputFiles;
}
} else {
// Filter and collect all files that match the inputFileExtension
List<Resource> matchingFiles = outputFiles.stream()
.filter(file -> file.getFilename().endsWith(finalInputFileExtension))
.collect(Collectors.toList());
// Check if there are matching files
if (!matchingFiles.isEmpty()) {
// Create a new MultiValueMap for the request body
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
// Add all matching files to the body
for (Resource file : matchingFiles) {
body.add("fileInput", file);
}
for(Entry<String, Object> entry : parameters.entrySet()) {
body.add(entry.getKey(), entry.getValue());
}
ResponseEntity<byte[]> response = sendWebRequest(url, body);
// Handle the response
if (response.getStatusCode().equals(HttpStatus.OK)) {
processOutputFiles(operation, matchingFiles.get(0).getFilename(), response, newOutputFiles);
} else {
// Log error if the response status is not OK
logPrintStream.println("Error in multi-input operation: " + response.getBody());
hasErrors = true;
}
} else {
logPrintStream.println("No files with extension " + inputFileExtension + " found for multi-input operation " + operation);
hasErrors = true;
}
}
logPrintStream.close();
}
if (hasErrors) {
logger.error("Errors occurred during processing. Log: {}", logStream.toString());
}
return outputFiles;
}
private ResponseEntity<byte[]> sendWebRequest(String url, MultiValueMap<String, Object> body ){
RestTemplate restTemplate = new RestTemplate();
// Set up headers, including API key
HttpHeaders headers = new HttpHeaders();
String apiKey = getApiKeyForUser();
headers.add("X-API-Key", apiKey);
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
// Create HttpEntity with the body and headers
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
// Make the request to the REST endpoint
return restTemplate.exchange(url, HttpMethod.POST, entity, byte[].class);
}
private List<Resource> processOutputFiles(String operation, String fileName, ResponseEntity<byte[]> response, List<Resource> newOutputFiles) throws IOException{
// Define filename
String newFilename;
if ("auto-rename".equals(operation)) {
// If the operation is "auto-rename", generate a new filename.
// This is a simple example of generating a filename using current timestamp.
// Modify as per your needs.
newFilename = "file_" + System.currentTimeMillis();
} else {
// Otherwise, keep the original filename.
newFilename = fileName;
}
// Check if the response body is a zip file
if (isZip(response.getBody())) {
// Unzip the file and add all the files to the new output files
newOutputFiles.addAll(unzip(response.getBody()));
} else {
Resource outputResource = new ByteArrayResource(response.getBody()) {
@Override
public String getFilename() {
return newFilename;
}
};
newOutputFiles.add(outputResource);
}
return newOutputFiles;
}
List<Resource> generateInputFiles(File[] files) throws Exception {
if (files == null || files.length == 0) {
logger.info("No files");
return null;
}
List<Resource> outputFiles = new ArrayList<>();
for (File file : files) {
Path path = Paths.get(file.getAbsolutePath());
logger.info("Reading file: " + path); // debug statement
if (Files.exists(path)) {
Resource fileResource = new ByteArrayResource(Files.readAllBytes(path)) {
@Override
public String getFilename() {
return file.getName();
}
};
outputFiles.add(fileResource);
} else {
logger.info("File not found: " + path);
}
}
logger.info("Files successfully loaded. Starting processing...");
return outputFiles;
}
List<Resource> generateInputFiles(MultipartFile[] files) throws Exception {
if (files == null || files.length == 0) {
logger.info("No files");
return null;
}
List<Resource> outputFiles = new ArrayList<>();
for (MultipartFile file : files) {
Resource fileResource = new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return file.getOriginalFilename();
}
};
outputFiles.add(fileResource);
}
logger.info("Files successfully loaded. Starting processing...");
return outputFiles;
}
private boolean isZip(byte[] data) {
if (data == null || data.length < 4) {
return false;
}
// Check the first four bytes of the data against the standard zip magic number
return data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04;
}
private List<Resource> unzip(byte[] data) throws IOException {
logger.info("Unzipping data of length: {}", data.length);
List<Resource> unzippedFiles = new ArrayList<>();
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ZipInputStream zis = new ZipInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int count;
while ((count = zis.read(buffer)) != -1) {
baos.write(buffer, 0, count);
}
final String filename = entry.getName();
Resource fileResource = new ByteArrayResource(baos.toByteArray()) {
@Override
public String getFilename() {
return filename;
}
};
// If the unzipped file is a zip file, unzip it
if (isZip(baos.toByteArray())) {
logger.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(baos.toByteArray()));
} else {
unzippedFiles.add(fileResource);
}
}
}
logger.info("Unzipping completed. {} files were unzipped.", unzippedFiles.size());
return unzippedFiles;
}
}

View File

@ -0,0 +1,4 @@
package stirling.software.SPDF.controller.api.pipeline;
public interface UserServiceInterface {
String getApiKeyForUser(String username);
}

View File

@ -1,4 +1,5 @@
package stirling.software.SPDF.controller.web;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
@ -15,6 +16,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.Authority;
import stirling.software.SPDF.model.Role;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.repository.UserRepository;
@Controller
@ -46,14 +49,28 @@ public class AccountWebController {
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/addUsers")
public String showAddUserForm(Model model, Authentication authentication) {
List<User> allUsers = userRepository.findAll();
List<User> allUsers = userRepository.findAll();
Iterator<User> iterator = allUsers.iterator();
while(iterator.hasNext()) {
User user = iterator.next();
if(user != null) {
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
iterator.remove();
break; // Break out of the inner loop once the user is removed
}
}
}
}
model.addAttribute("users", allUsers);
model.addAttribute("currentUsername", authentication.getName());
return "addUsers";
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/account")
public String account(HttpServletRequest request, Model model, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
@ -100,7 +117,7 @@ public class AccountWebController {
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/change-creds")
public String changeCreds(HttpServletRequest request, Model model, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {

View File

@ -1,5 +1,6 @@
package stirling.software.SPDF.controller.web;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@ -41,31 +42,44 @@ public class GeneralWebController {
model.addAttribute("currentPage", "pipeline");
List<String> pipelineConfigs = new ArrayList<>();
try (Stream<Path> paths = Files.walk(Paths.get("./pipeline/defaultWebUIConfigs/"))) {
List<Path> jsonFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".json"))
.collect(Collectors.toList());
for (Path jsonFile : jsonFiles) {
String content = Files.readString(jsonFile, StandardCharsets.UTF_8);
pipelineConfigs.add(content);
}
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent = new ObjectMapper().readValue(config, new TypeReference<Map<String, Object>>(){});
String name = (String) jsonContent.get("name");
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", config);
configWithName.put("name", name);
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
if(new File("./pipeline/defaultWebUIConfigs/").exists()) {
try (Stream<Path> paths = Files.walk(Paths.get("./pipeline/defaultWebUIConfigs/"))) {
List<Path> jsonFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".json"))
.collect(Collectors.toList());
for (Path jsonFile : jsonFiles) {
String content = Files.readString(jsonFile, StandardCharsets.UTF_8);
pipelineConfigs.add(content);
}
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent = new ObjectMapper().readValue(config, new TypeReference<Map<String, Object>>(){});
String name = (String) jsonContent.get("name");
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", config);
configWithName.put("name", name);
pipelineConfigsWithNames.add(configWithName);
}
} catch (IOException e) {
e.printStackTrace();
}
}
if(pipelineConfigsWithNames.size() == 0) {
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", "");
configWithName.put("name", "No preloaded configs found");
pipelineConfigsWithNames.add(configWithName);
}
model.addAttribute("pipelineConfigsWithNames", pipelineConfigsWithNames);
} catch (IOException e) {
e.printStackTrace();
}
model.addAttribute("pipelineConfigsWithNames", pipelineConfigsWithNames);
model.addAttribute("pipelineConfigs", pipelineConfigs);

View File

@ -27,8 +27,8 @@ import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.model.ApplicationProperties;
@RestController
@RequestMapping("/api/v1")
@Tag(name = "API", description = "Info APIs")
@RequestMapping("/api/v1/info")
@Tag(name = "Info", description = "Info APIs")
public class MetricsController {

View File

@ -0,0 +1,42 @@
package stirling.software.SPDF.model;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
public class ApiEndpoint {
private String name;
private Map<String, JsonNode> parameters;
private String description;
public ApiEndpoint(String name, JsonNode postNode) {
this.name = name;
this.parameters = new HashMap<>();
postNode.path("parameters").forEach(paramNode -> {
String paramName = paramNode.path("name").asText();
parameters.put(paramName, paramNode);
});
this.description = postNode.path("description").asText();
}
public boolean areParametersValid(Map<String, Object> providedParams) {
for (String requiredParam : parameters.keySet()) {
if (!providedParams.containsKey(requiredParam)) {
return false;
}
}
return true;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return "ApiEndpoint [name=" + name + ", parameters=" + parameters + "]";
}
}

View File

@ -106,6 +106,25 @@ public class ApplicationProperties {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin;
private int loginAttemptCount;
private long loginResetTimeMinutes;
public int getLoginAttemptCount() {
return loginAttemptCount;
}
public void setLoginAttemptCount(int loginAttemptCount) {
this.loginAttemptCount = loginAttemptCount;
}
public long getLoginResetTimeMinutes() {
return loginResetTimeMinutes;
}
public void setLoginResetTimeMinutes(long loginResetTimeMinutes) {
this.loginResetTimeMinutes = loginResetTimeMinutes;
}
public InitialLogin getInitialLogin() {
return initialLogin != null ? initialLogin : new InitialLogin();
@ -175,6 +194,19 @@ public class ApplicationProperties {
private String rootURIPath;
private String customStaticFilePath;
private Integer maxFileSize;
private Boolean enableAlphaFunctionality;
public Boolean getEnableAlphaFunctionality() {
return enableAlphaFunctionality;
}
public void setEnableAlphaFunctionality(Boolean enableAlphaFunctionality) {
this.enableAlphaFunctionality = enableAlphaFunctionality;
}
public String getDefaultLocale() {
return defaultLocale;
@ -218,12 +250,13 @@ public class ApplicationProperties {
@Override
public String toString() {
return "System [defaultLocale=" + defaultLocale + ", googlevisibility=" + googlevisibility + ", rootURIPath="
+ rootURIPath + ", customStaticFilePath=" + customStaticFilePath + ", maxFileSize=" + maxFileSize
+ "]";
return "System [defaultLocale=" + defaultLocale + ", googlevisibility=" + googlevisibility
+ ", rootURIPath=" + rootURIPath + ", customStaticFilePath=" + customStaticFilePath
+ ", maxFileSize=" + maxFileSize + ", enableAlphaFunctionality=" + enableAlphaFunctionality + "]";
}
}
public static class Ui {

View File

@ -0,0 +1,27 @@
package stirling.software.SPDF.model;
public class AttemptCounter {
private int attemptCount;
private long lastAttemptTime;
public AttemptCounter() {
this.attemptCount = 1;
this.lastAttemptTime = System.currentTimeMillis();
}
public void increment() {
this.attemptCount++;
this.lastAttemptTime = System.currentTimeMillis();
}
public int getAttemptCount() {
return attemptCount;
}
public long getlastAttemptTime() {
return lastAttemptTime;
}
public boolean shouldReset(long ATTEMPT_INCREMENT_TIME) {
return System.currentTimeMillis() - lastAttemptTime > ATTEMPT_INCREMENT_TIME;
}
}

View File

@ -14,8 +14,13 @@ public enum Role {
EXTRA_LIMITED_API_USER("ROLE_EXTRA_LIMITED_API_USER", 20, 20),
// 0 API calls per day and 20 web calls
WEB_ONLY_USER("ROLE_WEB_ONLY_USER", 0, 20);
WEB_ONLY_USER("ROLE_WEB_ONLY_USER", 0, 20),
INTERNAL_API_USER("STIRLING-PDF-BACKEND-API-USER", Integer.MAX_VALUE, Integer.MAX_VALUE),
DEMO_USER("ROLE_DEMO_USER", 100, 100);
private final String roleId;
private final int apiCallsPerDay;
private final int webCallsPerDay;

View File

@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
public class HandleDataRequest {
@Schema(description = "The input files")
private MultipartFile[] fileInputs;
private MultipartFile[] fileInput;
@Schema(description = "JSON String")
private String jsonString;
private String json;
}

View File

@ -5,6 +5,7 @@ import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -18,7 +19,7 @@ public class PDFWithPageNums extends PDFFile {
@Schema(description = "The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')\"")
private String pageNumbers;
@Hidden
public List<Integer> getPageNumbersList(){
int pageCount = 0;
try {
@ -30,6 +31,8 @@ public class PDFWithPageNums extends PDFFile {
return GeneralUtils.parsePageString(pageNumbers, pageCount);
}
@Hidden
public List<Integer> getPageNumbersList(PDDocument doc){
int pageCount = 0;
pageCount = doc.getNumberOfPages();

View File

@ -4,22 +4,17 @@ import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.ImageWriter;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;

View File

@ -0,0 +1,16 @@
package stirling.software.SPDF.utils;
public class RequestUriUtils {
public static boolean isStaticResource(String requestURI) {
return requestURI.startsWith("/css/")
|| requestURI.startsWith("/js/")
|| requestURI.startsWith("/images/")
|| requestURI.startsWith("/public/")
|| requestURI.startsWith("/pdfjs/")
|| requestURI.endsWith(".svg");
}
}

View File

@ -0,0 +1,6 @@
____ _____ ___ ____ _ ___ _ _ ____ ____ ____ _____
/ ___|_ _|_ _| _ \| | |_ _| \ | |/ ___| | _ \| _ \| ___|
\___ \ | | | || |_) | | | || \| | | _ _____| |_) | | | | |_
___) || | | || _ <| |___ | || |\ | |_| |_____| __/| |_| | _|
|____/ |_| |___|_| \_\_____|___|_| \_|\____| |_| |____/|_|
Powered by Spring Boot ${spring-boot.version}

View File

@ -119,6 +119,7 @@ adminUserSettings.role=Role
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Limited API User
adminUserSettings.webOnlyUser=Web Only User
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.forceChange=Force user to change username/password on login
adminUserSettings.submit=Save User

View File

@ -119,6 +119,7 @@ adminUserSettings.role=Role
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Limited API User
adminUserSettings.webOnlyUser=Web Only User
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.forceChange = Force user to change username/password on login
adminUserSettings.submit=Save User

View File

@ -5,10 +5,13 @@
security:
enableLogin: false # set to 'true' to enable login
csrfDisabled: true
loginAttemptCount: 5 # lock user account after 5 tries
loginResetTimeMinutes : 120 # lock account for 2 hours after x attempts
system:
defaultLocale: 'en-US' # Set the default language (e.g. 'de-DE', 'fr-FR', etc)
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
enableAlphaFunctionality: false # Set to enable functionality which might need more testing before it fully goes live (This feature might make no changes)
#ui:
# appName: exampleAppName # Application's visible name

View File

@ -75,6 +75,13 @@ table th, table td {
border: none;
color: #fff !important;
}
.btn-warning {
background-color: #ffc107 !important;
border: none;
color: #000 !important;
}
.btn-outline-secondary {
color: #fff !important;
border-color: #fff;
@ -92,6 +99,11 @@ hr {
background-color: rgba(255, 255, 255, 0.6); /* for some browsers that might use background instead of border for <hr> */
}
.modal-content {
color: #fff !important;
border-color: #fff;
}
#global-buttons-container input {
background-color: #323948;
caret-color: #ffffff;

View File

@ -1,5 +1,5 @@
#searchBar {
background-image: url('/images/search.svg');
background-image: url('../images/search.svg');
background-position: 16px 16px;
background-repeat: no-repeat;
width: 100%;

View File

@ -12,20 +12,17 @@ function validatePipeline() {
if (currentOperation === '/add-password') {
containsAddPassword = true;
}
console.log(currentOperation);
console.log(apiDocs[currentOperation]);
let currentOperationDescription = apiDocs[currentOperation]?.post?.description || "";
let nextOperationDescription = apiDocs[nextOperation]?.post?.description || "";
console.log("currentOperationDescription", currentOperationDescription);
console.log("nextOperationDescription", nextOperationDescription);
// Strip off 'ZIP-' prefix
currentOperationDescription = currentOperationDescription.replace("ZIP-", '');
nextOperationDescription = nextOperationDescription.replace("ZIP-", '');
let currentOperationOutput = currentOperationDescription.match(/Output:([A-Z\/]*)/)?.[1] || "";
let nextOperationInput = nextOperationDescription.match(/Input:([A-Z\/]*)/)?.[1] || "";
console.log("Operation " + currentOperation + " Output: " + currentOperationOutput);
console.log("Operation " + nextOperation + " Input: " + nextOperationInput);
// Splitting in case of multiple possible output/input
let currentOperationOutputArr = currentOperationOutput.split('/');
let nextOperationInputArr = nextOperationInput.split('/');
@ -35,6 +32,7 @@ function validatePipeline() {
console.log(`Intersection: ${intersection}`);
if (intersection.length === 0) {
updateValidateButton(false);
isValid = false;
console.log(`Incompatible operations: The output of operation '${currentOperation}' (${currentOperationOutput}) is not compatible with the input of the following operation '${nextOperation}' (${nextOperationInput}).`);
alert(`Incompatible operations: The output of operation '${currentOperation}' (${currentOperationOutput}) is not compatible with the input of the following operation '${nextOperation}' (${nextOperationInput}).`);
@ -43,6 +41,7 @@ function validatePipeline() {
}
}
if (containsAddPassword && pipelineListItems[pipelineListItems.length - 1].querySelector('.operationName').textContent !== '/add-password') {
updateValidateButton(false);
alert('The "add-password" operation should be at the end of the operations sequence. Please adjust the operations order.');
return false;
}
@ -53,10 +52,20 @@ function validatePipeline() {
console.error('Pipeline is not valid');
// Stop operation, maybe display an error to the user
}
updateValidateButton(isValid);
return isValid;
}
function updateValidateButton(isValid) {
var validateButton = document.getElementById('validateButton');
if (isValid) {
validateButton.classList.remove('btn-danger');
validateButton.classList.add('btn-success');
} else {
validateButton.classList.remove('btn-success');
validateButton.classList.add('btn-danger');
}
}
@ -67,14 +76,14 @@ document.getElementById('submitConfigBtn').addEventListener('click', function()
return;
}
let selectedOperation = document.getElementById('operationsDropdown').value;
let parameters = operationSettings[selectedOperation] || {};
var pipelineName = document.getElementById('pipelineName').value;
let pipelineList = document.getElementById('pipelineList').children;
let pipelineConfig = {
"name": "uniquePipelineName",
"pipeline": [{
"operation": selectedOperation,
"parameters": parameters
}],
"name": pipelineName,
"pipeline": [],
"_examples": {
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
@ -83,6 +92,28 @@ document.getElementById('submitConfigBtn').addEventListener('click', function()
"outputFileName": "{filename}"
};
for (let i = 0; i < pipelineList.length; i++) {
let operationName = pipelineList[i].querySelector('.operationName').textContent;
let parameters = operationSettings[operationName] || {};
pipelineConfig.pipeline.push({
"operation": operationName,
"parameters": parameters
});
}
let pipelineConfigJson = JSON.stringify(pipelineConfig, null, 2);
let formData = new FormData();
@ -99,37 +130,50 @@ document.getElementById('submitConfigBtn').addEventListener('click', function()
formData.append('json', pipelineConfigJson);
console.log("formData", formData);
fetch('/handleData', {
method: 'POST',
body: formData
fetch('api/v1/pipeline/handleData', {
method: 'POST',
body: formData
})
.then(response => response.blob())
.then(blob => {
.then(response => {
// Save the response to use it later
const responseToUseLater = response;
return response.blob().then(blob => {
let url = window.URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
// Use responseToUseLater instead of response
const contentDisposition = responseToUseLater.headers.get('Content-Disposition');
let filename = 'download';
if (contentDisposition && contentDisposition.indexOf('attachment') !== -1) {
filename = decodeURIComponent(contentDisposition.split('filename=')[1].replace(/"/g, '')).trim();
}
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
});
})
.catch((error) => {
console.error('Error:', error);
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = 'outputfile';
document.body.appendChild(a);
a.click();
a.remove();
})
.catch((error) => {
console.error('Error:', error);
});
});
let apiDocs = {};
let apiSchemas = {};
let operationSettings = {};
fetch('v3/api-docs')
fetch('v1/api-docs')
.then(response => response.json())
.then(data => {
apiDocs = data.paths;
apiSchemas = data.components.schemas;
let operationsDropdown = document.getElementById('operationsDropdown');
const ignoreOperations = ["/handleData", "operationToIgnore"]; // Add the operations you want to ignore here
const ignoreOperations = ["/api/v1/pipeline/handleData", "/api/v1/pipeline/operationToIgnore"]; // Add the operations you want to ignore here
operationsDropdown.innerHTML = '';
@ -138,6 +182,9 @@ fetch('v3/api-docs')
// Group operations by tags
Object.keys(data.paths).forEach(operationPath => {
let operation = data.paths[operationPath].post;
if(!operation || !operation.description) {
console.log(operationPath);
}
if (operation && !ignoreOperations.includes(operationPath) && !operation.description.includes("Type:MISO")) {
let operationTag = operation.tags[0]; // This assumes each operation has exactly one tag
if (!operationsByTag[operationTag]) {
@ -146,9 +193,8 @@ fetch('v3/api-docs')
operationsByTag[operationTag].push(operationPath);
}
});
// Specify the order of tags
let tagOrder = ["General", "Security", "Convert", "Other", "Filter"];
let tagOrder = ["General", "Security", "Convert", "Misc", "Filter"];
// Create dropdown options
tagOrder.forEach(tag => {
@ -158,8 +204,18 @@ fetch('v3/api-docs')
operationsByTag[tag].forEach(operationPath => {
let option = document.createElement('option');
let operationWithoutSlash = operationPath.replace(/\//g, ''); // Remove slashes
option.textContent = operationWithoutSlash;
let operationPathDisplay = operationPath
operationPathDisplay = operationPath.replace(new RegExp("api/v1/" + tag.toLowerCase() + "/", 'i'), "");
if(operationPath.includes("/convert")){
operationPathDisplay = operationPathDisplay.replace(/^\//, '').replaceAll("/", " to ");
} else {
operationPathDisplay = operationPathDisplay.replace(/\//g, ''); // Remove slashes
}
operationPathDisplay = operationPathDisplay.replaceAll(" ","-");
option.textContent = operationPathDisplay;
option.value = operationPath; // Keep the value with slashes for querying
group.appendChild(option);
});
@ -176,25 +232,40 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
let listItem = document.createElement('li');
listItem.className = "list-group-item";
let hasSettings = (apiDocs[selectedOperation] && apiDocs[selectedOperation].post &&
((apiDocs[selectedOperation].post.parameters && apiDocs[selectedOperation].post.parameters.length > 0) ||
(apiDocs[selectedOperation].post.requestBody &&
apiDocs[selectedOperation].post.requestBody.content['multipart/form-data'].schema.properties)));
let hasSettings = false;
if (apiDocs[selectedOperation] && apiDocs[selectedOperation].post) {
const postMethod = apiDocs[selectedOperation].post;
// Check if parameters exist
if (postMethod.parameters && postMethod.parameters.length > 0) {
hasSettings = true;
} else if (postMethod.requestBody && postMethod.requestBody.content['multipart/form-data']) {
// Extract the reference key
const refKey = postMethod.requestBody.content['multipart/form-data'].schema['$ref'].split('/').pop();
// Check if the referenced schema exists and has properties
if (apiSchemas[refKey] && Object.keys(apiSchemas[refKey].properties).length > 0) {
hasSettings = true;
}
}
}
listItem.innerHTML = `
<div class="d-flex justify-content-between align-items-center w-100">
<div class="operationName">${selectedOperation}</div>
<div class="arrows d-flex">
<button class="btn btn-secondary move-up btn-margin"><span>&uarr;</span></button>
<button class="btn btn-secondary move-down btn-margin"><span>&darr;</span></button>
<button class="btn btn-warning pipelineSettings btn-margin" ${hasSettings ? "" : "disabled"}><span style="color: ${hasSettings ? "black" : "grey"};"></span></button>
<button class="btn btn-danger remove"><span>X</span></button>
</div>
</div>
`;
<div class="d-flex justify-content-between align-items-center w-100">
<div class="operationName">${selectedOperation}</div>
<div class="arrows d-flex">
<button class="btn btn-secondary move-up ms-1"><span>&uarr;</span></button>
<button class="btn btn-secondary move-down ms-1"><span>&darr;</span></button>
<button class="btn ${hasSettings ? 'btn-warning' : 'btn-secondary'} pipelineSettings ms-1" ${hasSettings ? "" : "disabled"}>
<span style="color: ${hasSettings ? "white" : "grey"};"></span>
</button>
<button class="btn btn-danger remove ms-1"><span>X</span></button>
</div>
</div>
`;
pipelineList.appendChild(listItem);
@ -215,23 +286,28 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
listItem.querySelector('.remove').addEventListener('click', function(event) {
event.preventDefault();
pipelineList.removeChild(listItem);
hideOrShowPipelineHeader();
});
listItem.querySelector('.pipelineSettings').addEventListener('click', function(event) {
event.preventDefault();
showpipelineSettingsModal(selectedOperation);
hideOrShowPipelineHeader();
});
function showpipelineSettingsModal(operation) {
let pipelineSettingsModal = document.getElementById('pipelineSettingsModal');
let pipelineSettingsContent = document.getElementById('pipelineSettingsContent');
let operationData = apiDocs[operation].post.parameters || [];
let requestBodyData = apiDocs[operation].post.requestBody.content['multipart/form-data'].schema.properties || {};
// Resolve the $ref reference to get actual schema properties
let refKey = apiDocs[operation].post.requestBody.content['multipart/form-data'].schema['$ref'].split('/').pop();
let requestBodyData = apiSchemas[refKey].properties || {};
// Combine operationData and requestBodyData into a single array
operationData = operationData.concat(Object.keys(requestBodyData).map(key => ({
name: key,
schema: requestBodyData[key]
name: key,
schema: requestBodyData[key]
})));
pipelineSettingsContent.innerHTML = '';
@ -245,11 +321,15 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
let parameterLabel = document.createElement('label');
parameterLabel.textContent = `${parameter.name} (${parameter.schema.type}): `;
parameterLabel.title = parameter.description;
parameterLabel.title = parameter.schema.description;
parameterLabel.setAttribute('for', parameter.name);
parameterDiv.appendChild(parameterLabel);
let defaultValue = parameter.schema.example;
if (defaultValue === undefined) defaultValue = parameter.schema.default;
let parameterInput;
// check if enum exists in schema
if (parameter.schema.enum) {
// if enum exists, create a select element
@ -277,11 +357,12 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
parameterInput = document.createElement('input');
parameterInput.type = 'text';
parameterInput.className = "form-control";
parameterInput.value = "automatedFileInput";
parameterInput.value = "FileInputPathToBeInputtedManuallyOffline";
} else {
parameterInput = document.createElement('input');
parameterInput.type = 'text';
parameterInput.className = "form-control";
if (defaultValue !== undefined) parameterInput.value = defaultValue;
}
break;
case 'number':
@ -289,10 +370,12 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
parameterInput = document.createElement('input');
parameterInput.type = 'number';
parameterInput.className = "form-control";
if (defaultValue !== undefined) parameterInput.value = defaultValue;
break;
case 'boolean':
parameterInput = document.createElement('input');
parameterInput.type = 'checkbox';
if (defaultValue === true) parameterInput.checked = true;
break;
case 'array':
case 'object':
@ -304,10 +387,13 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
parameterInput = document.createElement('input');
parameterInput.type = 'text';
parameterInput.className = "form-control";
if (defaultValue !== undefined) parameterInput.value = defaultValue;
}
}
parameterInput.id = parameter.name;
console.log("defaultValue", defaultValue);
console.log("parameterInput", parameterInput);
if (operationSettings[operation] && operationSettings[operation][parameter.name] !== undefined) {
let savedValue = operationSettings[operation][parameter.name];
@ -327,7 +413,7 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
parameterInput.value = savedValue;
}
}
console.log("parameterInput2", parameterInput);
parameterDiv.appendChild(parameterInput);
pipelineSettingsContent.appendChild(parameterDiv);
@ -340,50 +426,65 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
event.preventDefault();
let settings = {};
operationData.forEach(parameter => {
let value = document.getElementById(parameter.name).value;
switch (parameter.schema.type) {
case 'number':
case 'integer':
settings[parameter.name] = Number(value);
break;
case 'boolean':
settings[parameter.name] = document.getElementById(parameter.name).checked;
break;
case 'array':
case 'object':
try {
settings[parameter.name] = JSON.parse(value);
} catch (err) {
console.error(`Invalid JSON format for ${parameter.name}`);
}
break;
default:
settings[parameter.name] = value;
if(parameter.name !== "fileInput"){
let value = document.getElementById(parameter.name).value;
switch (parameter.schema.type) {
case 'number':
case 'integer':
settings[parameter.name] = Number(value);
break;
case 'boolean':
settings[parameter.name] = document.getElementById(parameter.name).checked;
break;
case 'array':
case 'object':
try {
settings[parameter.name] = JSON.parse(value);
} catch (err) {
console.error(`Invalid JSON format for ${parameter.name}`);
}
break;
default:
settings[parameter.name] = value;
}
}
});
operationSettings[operation] = settings;
console.log(settings);
pipelineSettingsModal.style.display = "none";
//pipelineSettingsModal.style.display = "none";
});
pipelineSettingsContent.appendChild(saveButton);
pipelineSettingsModal.style.display = "block";
//pipelineSettingsModal.style.display = "block";
pipelineSettingsModal.getElementsByClassName("close")[0].onclick = function() {
pipelineSettingsModal.style.display = "none";
}
//pipelineSettingsModal.getElementsByClassName("close")[0].onclick = function() {
// pipelineSettingsModal.style.display = "none";
//}
window.onclick = function(event) {
if (event.target == pipelineSettingsModal) {
pipelineSettingsModal.style.display = "none";
}
}
//window.onclick = function(event) {
// if (event.target == pipelineSettingsModal) {
// pipelineSettingsModal.style.display = "none";
// }
//}
}
hideOrShowPipelineHeader();
});
var saveBtn = document.getElementById('savePipelineBtn');
document.getElementById('savePipelineBtn').addEventListener('click', function() {
// Remove any existing event listeners
saveBtn.removeEventListener('click', savePipeline);
// Add the event listener
saveBtn.addEventListener('click', savePipeline);
console.log("saveBtn", saveBtn)
function savePipeline() {
if (validatePipeline() === false) {
return;
}
var pipelineName = document.getElementById('pipelineName').value;
let pipelineList = document.getElementById('pipelineList').children;
let pipelineConfig = {
@ -393,31 +494,33 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
},
"outputDir": "httpWebRequest",
"outputDir": "{outputFolder}",
"outputFileName": "{filename}"
};
for (let i = 0; i < pipelineList.length; i++) {
let operationName = pipelineList[i].querySelector('.operationName').textContent;
let parameters = operationSettings[operationName] || {};
parameters['fileInput'] = 'automated';
pipelineConfig.pipeline.push({
"operation": operationName,
"parameters": parameters
});
}
console.log("Downloading..");
let a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(pipelineConfig, null, 2)], {
type: 'application/json'
}));
a.download = 'pipelineConfig.json';
a.download = pipelineName + '.json';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
async function processPipelineConfig(configString) {
let pipelineConfig = JSON.parse(configString);
@ -483,6 +586,7 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
processPipelineConfig(event.target.result);
};
reader.readAsText(e.target.files[0]);
hideOrShowPipelineHeader();
});
document.getElementById('pipelineSelect').addEventListener('change', function(e) {
@ -491,4 +595,15 @@ document.getElementById('addOperationBtn').addEventListener('click', function()
});
});
function hideOrShowPipelineHeader() {
var pipelineHeader = document.getElementById('pipelineHeader');
var pipelineList = document.getElementById('pipelineList');
if (pipelineList.children.length === 0) {
// Hide the pipeline header if there are no items in the pipeline list
pipelineHeader.style.display = 'none';
} else {
// Show the pipeline header if there are items in the pipeline list
pipelineHeader.style.display = 'block';
}
}

View File

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

View File

@ -62,6 +62,7 @@
<option value="ROLE_USER" th:text="#{adminUserSettings.user}">User</option>
<option value="ROLE_LIMITED_API_USER" th:text="#{adminUserSettings.apiUser}">Limited API User</option>
<option value="ROLE_WEB_ONLY_USER" th:text="#{adminUserSettings.webOnlyUser}">Web Only User</option>
<option value="ROLE_DEMO_USER" th:text="#{adminUserSettings.demoUser}">Demo User</option>
</select>
</div>
<div class="mb-3">

View File

@ -13,7 +13,7 @@
<div class="row justify-content-center">
<div class="col-md-6">
<h2 th:text="#{PDFToCSV.header}"></h2>
<form id="PDFToCSVForm" th:action="@{api/v1/convert/pdf-to-csv}" method="post" enctype="multipart/form-data">
<form id="PDFToCSVForm" th:action="@{api/v1/convert/pdf/csv}" method="post" enctype="multipart/form-data">
<input id="pageId" type="hidden" name="pageId" />
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false, accept='application/pdf')}"></div>
<button type="submit" class="btn btn-primary" th:text="#{PDFToCSV.submit}"></button>

View File

@ -32,12 +32,12 @@
<span class="icon-text" th:text="#{home.multiTool.title}"></span>
</a>
</li>
<!--<li class="nav-item">
<li th:if="${@enableAlphaFunctionality}" class="nav-item">
<a class="nav-link" href="#" th:href="@{pipeline}" th:classappend="${currentPage}=='pipeline' ? 'active' : ''" th:title="#{home.pipeline.desc}">
<img class="icon" src="images/pipeline.svg" alt="icon">
<span class="icon-text" th:text="#{home.pipeline.title}"></span>
</a>
</li>-->
</li>
<li class="nav-item nav-item-separator"></li>
<li class="nav-item dropdown" th:classappend="${currentPage}=='remove-pages' OR ${currentPage}=='merge-pdfs' OR ${currentPage}=='split-pdfs' OR ${currentPage}=='crop' OR ${currentPage}=='adjust-contrast' OR ${currentPage}=='pdf-organizer' OR ${currentPage}=='rotate-pdf' OR ${currentPage}=='multi-page-layout' OR ${currentPage}=='scale-pages' OR ${currentPage}=='auto-split-pdf' OR ${currentPage}=='extract-page' OR ${currentPage}=='pdf-to-single-page' ? 'active' : ''">
@ -185,7 +185,7 @@
<!-- Search Bar -->
<div class="collapse position-absolute" id="navbarSearch">
<form class="d-flex p-2 bg-white border search-form" id="searchForm">
<input class="form-control search-input" type="search" placeholder="Search" aria-label="Search" id="navbarSearchInput">
<input class="form-control search-input" type="search" th:placeholder="#{home.searchBar}" aria-label="Search" id="navbarSearchInput">
</form>
<!-- Search Results -->
<div id="searchResults" class="border p-2 bg-white search-results"></div>
@ -293,7 +293,7 @@
</a>
</div>
<div class="modal-footer">
<a th:if="${@loginEnabled}" href="/logout">
<a th:if="${@loginEnabled}" href="logout">
<button type="button" class="btn btn-danger" th:text="#{settings.signOut}">Sign Out</button>
</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}"></button>

View File

@ -27,9 +27,10 @@
<input type="text" id="searchBar" onkeyup="filterCards()" th:placeholder="#{home.searchBar}">
<div class="features-container ">
<!-- <div th:replace="~{fragments/card :: card(id='pipeline', cardTitle=#{home.pipeline.title}, cardText=#{home.pipeline.desc}, cardLink='pipeline', svgPath='images/pipeline.svg')}"></div> -->
<th:block th:if="${@enableAlphaFunctionality}">
<div th:replace="~{fragments/card :: card(id='pipeline', cardTitle=#{home.pipeline.title}, cardText=#{home.pipeline.desc}, cardLink='pipeline', svgPath='images/pipeline.svg')}"></div>
</th:block>
<div th:replace="~{fragments/card :: card(id='view-pdf', cardTitle=#{home.viewPdf.title}, cardText=#{home.viewPdf.desc}, cardLink='view-pdf', svgPath='images/book-opened.svg')}"></div>
<div th:replace="~{fragments/card :: card(id='multi-tool', cardTitle=#{home.multiTool.title}, cardText=#{home.multiTool.desc}, cardLink='multi-tool', svgPath='images/tools.svg')}"></div>
<div th:replace="~{fragments/card :: card(id='merge-pdfs', cardTitle=#{home.merge.title}, cardText=#{home.merge.desc}, cardLink='merge-pdfs', svgPath='images/union.svg')}"></div>

View File

@ -35,7 +35,8 @@
<br> <br>
<div class="container">
<div class="row justify-content-center">
<h1>(Alpha) Pipeline Menu (Huge work in progress, very buggy!)</h1>
<div class="bordered-box">
<div class="text-end text-top">
<button id="uploadPipelineBtn" class="btn btn-primary">Upload
@ -47,7 +48,6 @@
<div class="center-element">
<div class="element-margin">
<select id="pipelineSelect" class="custom-select">
<option value="">Select a pipeline</option>
<th:block th:each="config : ${pipelineConfigsWithNames}">
<option th:value="${config.json}" th:text="${config.name}"></option>
</th:block>
@ -62,16 +62,83 @@
</div>
</div>
<h3>Current Limitations</h3>
<ul>
<li>Cannot have more than one of the same operation</li>
<li>Cannot input additional files via UI</li>
<li>Does not work with multi-input functions yet (like merges)</li>
<li>All files and operations run in serial mode</li>
</ul>
<h3>How it Works Notes</h3>
<ul>
<li>Configure the pipeline config file and input files to run files against it</li>
<li>For reuse, download the config file and re-upload it when needed, or place it in /pipeline/defaultWebUIConfigs/ to auto-load in the web UI for all users</li>
</ul>
<h3>How to use pre-load configs in web UI</h3>
<ul>
<li>Download config files</li>
<li>For reuse, download the config file and re-upload it when needed, or place it in /pipeline/defaultWebUIConfigs/ to auto-load in the web UI for all users</li>
</ul>
<h3>Todo</h3>
<ul>
<li>fix initial config selected not loading</li>
<li>Fix operation adding requering settings to be openned and saved instead of saving defaults</li>
<li>multiInput support</li>
<li>Translation support</li>
<li>offline mode checks and testing</li>
<li>Improve operation config settings UI</li>
</ul>
<h2>User Guide for Local Directory Scanning and File Processing</h2>
<h3>Setting Up Watched Folders:</h3>
<p>Create a folder where you want your files to be monitored. This is your 'watched folder'.</p>
<p>The default directory for this is <code>./pipeline/watchedFolders/</code></p>
<p>Place any directories you want to be scanned into this folder, this folder should contain multiple folders each for their own tasks and pipelines.</p>
<h3>Configuring Processing with JSON Files:</h3>
<p>In each directory you want processed (e.g <code>./pipeline/watchedFolders/officePrinter</code>), include a JSON configuration file.</p>
<p>This JSON file should specify how you want the files in the directory to be handled (e.g., what operations to perform on them) which can be made, configured and downloaded from Stirling-PDF Pipeline interface.</p>
<h3>Automatic Scanning and Processing:</h3>
<p>The system automatically checks the watched folder every minute for new directories and files to process.</p>
<p>When a directory with a valid JSON configuration file is found, it begins processing the files inside as per the configuration.</p>
<h3>Processing Steps:</h3>
<p>Files in each directory are processed according to the instructions in the JSON file.</p>
<p>This might involve file conversions, data filtering, renaming files, etc. If the output of a step is a zip, this zip will be automatically unzipped as it passes to next process.</p>
<h3>Results and Output:</h3>
<p>After processing, the results are saved in a specified output location. This could be a different folder or location as defined in the JSON file or the default location <code>./pipeline/finishedFolders/</code>.</p>
<p>Each processed file is named and organized according to the rules set in the JSON configuration.</p>
<h3>Completion and Cleanup:</h3>
<p>Once processing is complete, the original files in the watched folder's directory are removed.</p>
<p>You can find the processed files in the designated output location.</p>
<h3>Error Handling:</h3>
<p>If there's an error during processing, the system will not delete the original files, allowing you to check and retry if necessary.</p>
<h3>User Interaction:</h3>
<p>As a user, your main tasks are to set up the watched folders, place directories with files for processing, and create the corresponding JSON configuration files.</p>
<p>The system handles the rest, including scanning, processing, and outputting results.</p>
<!-- The Modal -->
<div class="modal" id="pipelineSettingsModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-content dark-card">
<!-- Modal Header -->
<div class="modal-header">
<h2 class="modal-title">Pipeline Configuration</h2>
<button type="button" class="close" data-bs-dismiss="modal">&times;</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Modal body -->
@ -90,7 +157,7 @@
<button id="addOperationBtn" class="btn btn-primary">Add
operation</button>
</div>
<h3>Pipeline:</h3>
<h3 id="pipelineHeader" style="display: none;">Pipeline:</h3>
<ol id="pipelineList" class="list-group">
<!-- Pipeline operations will be dynamically populated here -->
</ol>