From df31733501dc1ec7707623eb7335fe5fc3ad71b1 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Sat, 22 Mar 2025 18:22:46 +0100 Subject: [PATCH 01/61] Add support for expired sessions and improve user management --- .../security/UserAuthenticationFilter.java | 70 ++++++++++++--- .../SPDF/config/security/UserService.java | 20 +---- .../SPDF/config/security/UserUtils.java | 22 +++++ ...tomOAuth2AuthenticationSuccessHandler.java | 10 +-- .../session/CustomHttpSessionListener.java | 33 ++++++- .../session/SessionPersistentRegistry.java | 89 +++++++++---------- .../security/session/SessionRepository.java | 3 +- .../security/session/SessionScheduled.java | 29 +++++- .../controller/web/AccountWebController.java | 1 + 9 files changed, 185 insertions(+), 92 deletions(-) create mode 100644 src/main/java/stirling/software/SPDF/config/security/UserUtils.java diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index 714096c6..494d1d84 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -22,6 +22,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; @@ -32,6 +33,7 @@ import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.SAML2; +import stirling.software.SPDF.model.SessionEntity; import stirling.software.SPDF.model.User; @Slf4j @@ -59,26 +61,66 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated() && loginEnabledValue) { + Object principalTest = authentication.getPrincipal(); + String username = UserUtils.getUsernameFromPrincipal(principalTest); + + log.info("Principal: {}", username); + List allSessions = + sessionPersistentRegistry.getAllSessions(username, false); + + HttpSession session = request.getSession(false); + if (session == null) { + session = request.getSession(true); + } + + String sessionId = request.getSession(false).getId(); + + log.info("allSessions: {} username: {}", allSessions.size(), username); + + for (SessionInformation sessionInformation : allSessions) { + if (sessionId.equals(sessionInformation.getSessionId())) { + log.info("Session found: {}", sessionId); + log.info("lastRequest: {}", sessionInformation.getLastRequest()); + sessionPersistentRegistry.refreshLastRequest(sessionId); + SessionInformation sessionInfo = + sessionPersistentRegistry.getSessionInformation(sessionId); + log.info("new lastRequest: {}", sessionInfo.getLastRequest()); + } else if (allSessions.size() > 2) { + sessionPersistentRegistry.expireSession(sessionId); + sessionInformation.expireNow(); + authentication.setAuthenticated(false); + SecurityContextHolder.clearContext(); + request.getSession().invalidate(); + log.info( + "Expired session: {} Date: {}", + sessionInformation.getSessionId(), + sessionInformation.getLastRequest()); + response.sendRedirect(request.getContextPath() + "/login?error=expiredSession"); + return; + } + } + allSessions = sessionPersistentRegistry.getAllSessions(username, false); + + SessionEntity sessionEntity = sessionPersistentRegistry.getSessionEntity(sessionId); + + if (allSessions.isEmpty() || sessionEntity.isExpired()) { + log.info("No sessions found for user: {}", username); + sessionPersistentRegistry.expireSession(sessionId); + authentication.setAuthenticated(false); + SecurityContextHolder.clearContext(); + response.sendRedirect(request.getContextPath() + "/login?error=expiredSession"); + return; + } + } if (!loginEnabledValue) { // If login is not enabled, just pass all requests without authentication filterChain.doFilter(request, response); return; } String requestURI = request.getRequestURI(); - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - // Check for session expiration (unsure if needed) - // if (authentication != null && authentication.isAuthenticated()) { - // String sessionId = request.getSession().getId(); - // SessionInformation sessionInfo = - // sessionPersistentRegistry.getSessionInformation(sessionId); - // - // if (sessionInfo != null && sessionInfo.isExpired()) { - // SecurityContextHolder.clearContext(); - // response.sendRedirect(request.getContextPath() + "/login?expired=true"); - // return; - // } - // } + // authentication = SecurityContextHolder.getContext().getAuthentication(); } // Check for API key in the request headers if no authentication exists if (authentication == null || !authentication.isAuthenticated()) { diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index 61b7c40a..49cd7dfd 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -12,7 +12,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; @@ -380,23 +379,10 @@ public class UserService implements UserServiceInterface { } public void invalidateUserSessions(String username) { - String usernameP = ""; - for (Object principal : sessionRegistry.getAllPrincipals()) { - for (SessionInformation sessionsInformation : - sessionRegistry.getAllSessions(principal, false)) { - if (principal instanceof UserDetails detailsUser) { - usernameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - usernameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - usernameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - usernameP = stringUser; - } - if (usernameP.equalsIgnoreCase(username)) { - sessionRegistry.expireSession(sessionsInformation.getSessionId()); - } + String usernameP = UserUtils.getUsernameFromPrincipal(principal); + if (usernameP.equalsIgnoreCase(username)) { + sessionRegistry.expireAllSessionsByPrincipalName(usernameP); } } } diff --git a/src/main/java/stirling/software/SPDF/config/security/UserUtils.java b/src/main/java/stirling/software/SPDF/config/security/UserUtils.java new file mode 100644 index 00000000..a2c03ac1 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/UserUtils.java @@ -0,0 +1,22 @@ +package stirling.software.SPDF.config.security; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; + +public class UserUtils { + public static String getUsernameFromPrincipal(Object principal) { + if (principal instanceof UserDetails detailsUser) { + return detailsUser.getUsername(); + } else if (principal instanceof OAuth2User oAuth2User) { + return oAuth2User.getName(); + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { + return saml2User.name(); + } else if (principal instanceof String stringUser) { + return stringUser; + } else { + return null; + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 4ee49aed..1b4d84e5 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -5,7 +5,6 @@ import java.sql.SQLException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; @@ -17,6 +16,7 @@ import jakarta.servlet.http.HttpSession; import stirling.software.SPDF.config.security.LoginAttemptService; import stirling.software.SPDF.config.security.UserService; +import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.AuthenticationType; @@ -45,13 +45,7 @@ public class CustomOAuth2AuthenticationSuccessHandler throws ServletException, IOException { Object principal = authentication.getPrincipal(); - String username = ""; - - if (principal instanceof OAuth2User oAuth2User) { - username = oAuth2User.getName(); - } else if (principal instanceof UserDetails detailsUser) { - username = detailsUser.getUsername(); - } + String username = UserUtils.getUsernameFromPrincipal(principal); // Get the saved request HttpSession session = request.getSession(false); diff --git a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java index 3d97181a..dd0b0102 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java @@ -1,6 +1,8 @@ package stirling.software.SPDF.config.security.session; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import jakarta.servlet.http.HttpSessionEvent; @@ -8,20 +10,43 @@ import jakarta.servlet.http.HttpSessionListener; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.security.UserUtils; + @Component @Slf4j public class CustomHttpSessionListener implements HttpSessionListener { - private SessionPersistentRegistry sessionPersistentRegistry; + private final SessionPersistentRegistry sessionPersistentRegistry; - @Autowired public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) { super(); this.sessionPersistentRegistry = sessionPersistentRegistry; } @Override - public void sessionCreated(HttpSessionEvent se) {} + public void sessionCreated(HttpSessionEvent se) { + SecurityContext securityContext = SecurityContextHolder.getContext(); + if (securityContext == null) { + log.debug("Security context is null"); + return; + } + Authentication authentication = securityContext.getAuthentication(); + if (authentication == null) { + log.info("Authentication is null"); + return; + } + Object principal = authentication.getPrincipal(); + if (principal == null) { + log.info("Principal is null"); + return; + } + String principalName = UserUtils.getUsernameFromPrincipal(principal); + if (principalName == null || "anonymousUser".equals(principalName)) { + return; + } + log.info("Session created: {}", principalName); + sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName); + } @Override public void sessionDestroyed(HttpSessionEvent se) { diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java index 18b03716..65a9e94e 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java @@ -1,26 +1,31 @@ package stirling.software.SPDF.config.security.session; import java.time.Duration; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Optional; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import jakarta.transaction.Transactional; -import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.model.SessionEntity; @Component +@Slf4j public class SessionPersistentRegistry implements SessionRegistry { private final SessionRepository sessionRepository; - @Value("${server.servlet.session.timeout:30m}") + @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m private Duration defaultMaxInactiveInterval; public SessionPersistentRegistry(SessionRepository sessionRepository) { @@ -41,17 +46,7 @@ public class SessionPersistentRegistry implements SessionRegistry { public List getAllSessions( Object principal, boolean includeExpiredSessions) { List sessionInformations = new ArrayList<>(); - String principalName = null; - - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; - } + String principalName = UserUtils.getUsernameFromPrincipal(principal); if (principalName != null) { List sessionEntities = @@ -72,29 +67,15 @@ public class SessionPersistentRegistry implements SessionRegistry { @Override @Transactional public void registerNewSession(String sessionId, Object principal) { - String principalName = null; - - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; - } + String principalName = UserUtils.getUsernameFromPrincipal(principal); if (principalName != null) { - // Clear old sessions for the principal (unsure if needed) - // List existingSessions = - // sessionRepository.findByPrincipalName(principalName); - // for (SessionEntity session : existingSessions) { - // session.setExpired(true); - // sessionRepository.save(session); - // } - - SessionEntity sessionEntity = new SessionEntity(); - sessionEntity.setSessionId(sessionId); + SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId); + if (sessionEntity == null) { + sessionEntity = new SessionEntity(); + sessionEntity.setSessionId(sessionId); + log.info("Registering new session for principal: {}", principalName); + } sessionEntity.setPrincipalName(principalName); sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date sessionEntity.setExpired(false); @@ -111,11 +92,12 @@ public class SessionPersistentRegistry implements SessionRegistry { @Override @Transactional public void refreshLastRequest(String sessionId) { - Optional sessionEntityOpt = sessionRepository.findById(sessionId); - if (sessionEntityOpt.isPresent()) { - SessionEntity sessionEntity = sessionEntityOpt.get(); + SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId); + if (sessionEntity != null) { sessionEntity.setLastRequest(new Date()); // Update lastRequest to the current date sessionRepository.save(sessionEntity); + } else { + log.error("Session not found for session ID: {}", sessionId); } } @@ -152,6 +134,15 @@ public class SessionPersistentRegistry implements SessionRegistry { } } + // Mark all sessions as expired for a given principal name + public void expireAllSessionsByPrincipalName(String principalName) { + List sessionEntities = sessionRepository.findByPrincipalName(principalName); + for (SessionEntity sessionEntity : sessionEntities) { + sessionEntity.setExpired(true); // Set expired to true + sessionRepository.save(sessionEntity); + } + } + // Get the maximum inactive interval for sessions public int getMaxInactiveInterval() { return (int) defaultMaxInactiveInterval.getSeconds(); @@ -168,6 +159,15 @@ public class SessionPersistentRegistry implements SessionRegistry { sessionRepository.saveByPrincipalName(expired, lastRequest, principalName); } + // Update session details by session ID + public void updateSessionBySessionId(String sessionId) { + SessionEntity sessionEntity = getSessionEntity(sessionId); + if (sessionEntity != null) { + sessionEntity.setLastRequest(new Date()); + sessionRepository.save(sessionEntity); + } + } + // Find the latest session for a given principal name public Optional findLatestSession(String principalName) { List allSessions = sessionRepository.findByPrincipalName(principalName); @@ -178,13 +178,8 @@ public class SessionPersistentRegistry implements SessionRegistry { // Sort sessions by lastRequest in descending order Collections.sort( allSessions, - new Comparator() { - @Override - public int compare(SessionEntity s1, SessionEntity s2) { - // Sort by lastRequest in descending order - return s2.getLastRequest().compareTo(s1.getLastRequest()); - } - }); + (SessionEntity s1, SessionEntity s2) -> + s2.getLastRequest().compareTo(s1.getLastRequest())); // The first session in the list is the latest session for the given principal name return Optional.of(allSessions.get(0)); diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java index b7f0133f..933c2012 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java @@ -24,7 +24,8 @@ public interface SessionRepository extends JpaRepository @Modifying @Transactional @Query( - "UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE s.principalName = :principalName") + "UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE" + + " s.principalName = :principalName") void saveByPrincipalName( @Param("expired") boolean expired, @Param("lastRequest") Date lastRequest, diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java index 9710316e..e6d52421 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java @@ -6,10 +6,15 @@ import java.util.Date; import java.util.List; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; + @Component +@Slf4j public class SessionScheduled { private final SessionPersistentRegistry sessionPersistentRegistry; @@ -18,10 +23,18 @@ public class SessionScheduled { this.sessionPersistentRegistry = sessionPersistentRegistry; } - @Scheduled(cron = "0 0/5 * * * ?") + @Scheduled(cron = "0 0/1 * * * ?") // TODO: Change to 5m public void expireSessions() { Instant now = Instant.now(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); for (Object principal : sessionPersistentRegistry.getAllPrincipals()) { + if (principal == null) { + continue; + } else if (principal instanceof String stringPrincipal) { + if ("anonymousUser".equals(stringPrincipal)) { + continue; + } + } List sessionInformations = sessionPersistentRegistry.getAllSessions(principal, false); for (SessionInformation sessionInformation : sessionInformations) { @@ -30,7 +43,21 @@ public class SessionScheduled { Instant expirationTime = lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); if (now.isAfter(expirationTime)) { + log.info( + "SessionID: {} expiration time: {} Current time: {}", + sessionInformation.getSessionId(), + expirationTime, + now); sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); + sessionInformation.expireNow(); + if (authentication != null && principal.equals(authentication.getPrincipal())) { + authentication.setAuthenticated(false); + } + SecurityContextHolder.clearContext(); + log.info( + "Session expired for principal: {} SessionID: {}", + principal, + sessionInformation.getSessionId()); } } } diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 65e1d055..1396772f 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -147,6 +147,7 @@ public class AccountWebController { case "badCredentials" -> error = "login.invalid"; case "locked" -> error = "login.locked"; case "oauth2AuthenticationError" -> error = "userAlreadyExistsOAuthMessage"; + case "expiredSession" -> error = "expiredSessionMessage"; } model.addAttribute("error", error); From d0ed33f2cff847b6a25c57eb3a5eed37a6aa34a3 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Sat, 22 Mar 2025 20:36:12 +0100 Subject: [PATCH 02/61] Update AppUpdateAuthService.java --- .../software/SPDF/config/security/AppUpdateAuthService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java b/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java index c8a13322..7e44f7e2 100644 --- a/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java +++ b/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java @@ -35,7 +35,7 @@ class AppUpdateAuthService implements ShowAdminInterface { if (authentication == null || !authentication.isAuthenticated()) { return !showUpdateOnlyAdmin; } - if (authentication.getName().equalsIgnoreCase("anonymousUser")) { + if ("anonymousUser".equalsIgnoreCase(authentication.getName())) { return !showUpdateOnlyAdmin; } Optional user = userRepository.findByUsername(authentication.getName()); From 969ca7be50a780e5edc0164bed445e402b0c857d Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Sun, 23 Mar 2025 21:18:54 +0100 Subject: [PATCH 03/61] add runningEE and more --- .../security/UserAuthenticationFilter.java | 54 +++++++++++-------- .../session/SessionPersistentRegistry.java | 14 ++++- .../session/SessionRegistryConfig.java | 5 +- .../SPDF/controller/api/UserController.java | 16 ++---- .../controller/web/AccountWebController.java | 10 +++- src/main/resources/templates/addUsers.html | 5 +- 6 files changed, 63 insertions(+), 41 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index 494d1d84..61df11d1 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.config.security; import java.io.IOException; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -66,43 +67,50 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { Object principalTest = authentication.getPrincipal(); String username = UserUtils.getUsernameFromPrincipal(principalTest); - log.info("Principal: {}", username); List allSessions = sessionPersistentRegistry.getAllSessions(username, false); + int userSessions = allSessions.size(); + HttpSession session = request.getSession(false); if (session == null) { - session = request.getSession(true); + filterChain.doFilter(request, response); + return; } + String sessionId = session.getId(); - String sessionId = request.getSession(false).getId(); - - log.info("allSessions: {} username: {}", allSessions.size(), username); - + if (allSessions.size() > 2) { + // Sortiere nach letzter Aktivität – älteste zuerst + List sortedSessions = + allSessions.stream() + .sorted(Comparator.comparing(SessionInformation::getLastRequest)) + .collect(Collectors.toList()); + int sessionsToExpire = allSessions.size() - 2; + for (int i = 0; i < sessionsToExpire; i++) { + SessionInformation oldSession = sortedSessions.get(i); + if (!sessionId.equals(oldSession.getSessionId())) { + sessionPersistentRegistry.expireSession(oldSession.getSessionId()); + oldSession.expireNow(); + log.info( + "Expired old session: {} (last request: {})", + oldSession.getSessionId(), + oldSession.getLastRequest()); + } + } + } for (SessionInformation sessionInformation : allSessions) { if (sessionId.equals(sessionInformation.getSessionId())) { - log.info("Session found: {}", sessionId); - log.info("lastRequest: {}", sessionInformation.getLastRequest()); sessionPersistentRegistry.refreshLastRequest(sessionId); - SessionInformation sessionInfo = - sessionPersistentRegistry.getSessionInformation(sessionId); - log.info("new lastRequest: {}", sessionInfo.getLastRequest()); - } else if (allSessions.size() > 2) { - sessionPersistentRegistry.expireSession(sessionId); - sessionInformation.expireNow(); - authentication.setAuthenticated(false); - SecurityContextHolder.clearContext(); - request.getSession().invalidate(); - log.info( - "Expired session: {} Date: {}", - sessionInformation.getSessionId(), - sessionInformation.getLastRequest()); - response.sendRedirect(request.getContextPath() + "/login?error=expiredSession"); - return; } } allSessions = sessionPersistentRegistry.getAllSessions(username, false); + log.info( + "username: {} || before Sessions: {} | after Sessions: {}", + username, + userSessions, + allSessions.size()); + SessionEntity sessionEntity = sessionPersistentRegistry.getSessionEntity(sessionId); if (allSessions.isEmpty() || sessionEntity.isExpired()) { diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java index 65a9e94e..94f87b01 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java @@ -7,6 +7,7 @@ import java.util.Date; import java.util.List; import java.util.Optional; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; @@ -24,11 +25,14 @@ import stirling.software.SPDF.model.SessionEntity; public class SessionPersistentRegistry implements SessionRegistry { private final SessionRepository sessionRepository; + private final boolean runningEE; @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m private Duration defaultMaxInactiveInterval; - public SessionPersistentRegistry(SessionRepository sessionRepository) { + public SessionPersistentRegistry( + SessionRepository sessionRepository, @Qualifier("runningEE") boolean runningEE) { + this.runningEE = runningEE; this.sessionRepository = sessionRepository; } @@ -184,4 +188,12 @@ public class SessionPersistentRegistry implements SessionRegistry { // The first session in the list is the latest session for the given principal name return Optional.of(allSessions.get(0)); } + + // Get the maximum number of sessions + public int getMaxSessions() { + if (runningEE) { + return Integer.MAX_VALUE; + } + return 30; + } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java index 8fa24e95..750cf740 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.config.security.session; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.session.SessionRegistryImpl; @@ -14,7 +15,7 @@ public class SessionRegistryConfig { @Bean public SessionPersistentRegistry sessionPersistentRegistry( - SessionRepository sessionRepository) { - return new SessionPersistentRegistry(sessionRepository); + SessionRepository sessionRepository, @Qualifier("runningEE") boolean runningEE) { + return new SessionPersistentRegistry(sessionRepository, runningEE); } } diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index c676169c..c93f652e 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -13,8 +13,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.session.SessionInformation; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -30,7 +28,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.security.UserService; -import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.AuthenticationType; import stirling.software.SPDF.model.Role; @@ -293,19 +291,11 @@ public class UserController { if (!enabled) { // Invalidate all sessions if the user is being disabled List principals = sessionRegistry.getAllPrincipals(); - String userNameP = ""; for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; - } + String userNameP = UserUtils.getUsernameFromPrincipal(principal); + if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { sessionRegistry.expireSession(sessionInfo.getSessionId()); diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 1396772f..c49d1872 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -14,6 +14,7 @@ import java.util.stream.Collectors; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Controller; @@ -205,8 +206,10 @@ public class AccountWebController { // Map to store session information and user activity status Map userSessions = new HashMap<>(); Map userLastRequest = new HashMap<>(); + Map userActiveSessions = new HashMap<>(); int activeUsers = 0; int disabledUsers = 0; + int maxSessions = sessionPersistentRegistry.getMaxSessions(); while (iterator.hasNext()) { User user = iterator.next(); if (user != null) { @@ -221,7 +224,7 @@ public class AccountWebController { // Determine the user's session status and last request time int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); boolean hasActiveSession = false; - Date lastRequest = null; + Date lastRequest; Optional latestSession = sessionPersistentRegistry.findLatestSession(user.getUsername()); if (latestSession.isPresent()) { @@ -251,6 +254,9 @@ public class AccountWebController { if (!user.isEnabled()) { disabledUsers++; } + List sessionInformations = + sessionPersistentRegistry.getAllSessions(user.getUsername(), false); + userActiveSessions.put(user.getUsername(), sessionInformations.size()); } } // Sort users by active status and last request date @@ -316,9 +322,11 @@ public class AccountWebController { model.addAttribute("roleDetails", roleDetails); model.addAttribute("userSessions", userSessions); model.addAttribute("userLastRequest", userLastRequest); + model.addAttribute("userActiveSessions", userActiveSessions); model.addAttribute("totalUsers", allUsers.size()); model.addAttribute("activeUsers", activeUsers); model.addAttribute("disabledUsers", disabledUsers); + model.addAttribute("maxSessions", maxSessions); return "addUsers"; } diff --git a/src/main/resources/templates/addUsers.html b/src/main/resources/templates/addUsers.html index 9b6475f3..b49cb879 100644 --- a/src/main/resources/templates/addUsers.html +++ b/src/main/resources/templates/addUsers.html @@ -25,7 +25,7 @@

-
+
manage_accounts Admin User Control Settings @@ -45,6 +45,7 @@ Total Users: Active Users: Disabled Users: + Max Sessions
@@ -69,6 +70,7 @@ Roles Authenticated Last Request + Sessions Actions @@ -80,6 +82,7 @@ +
From cedda44bb0c290c1677208ab360b7c4d088a87ba Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:50:06 +0100 Subject: [PATCH 04/61] Update SecurityConfiguration.java --- .../software/SPDF/config/security/SecurityConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index 289071b0..3fd9db48 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -149,7 +149,7 @@ public class SecurityConfiguration { sessionManagement -> sessionManagement .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .maximumSessions(10) + .maximumSessions(sessionRegistry.getMaxUserSessions()) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry) .expiredUrl("/login?logout=true")); From 3bb1bfa3992f377a0fcb6996a23a22a6db6e6bb1 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:50:18 +0100 Subject: [PATCH 05/61] Update SessionPersistentRegistry.java --- .../security/session/SessionPersistentRegistry.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java index 94f87b01..8db40423 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java @@ -194,6 +194,14 @@ public class SessionPersistentRegistry implements SessionRegistry { if (runningEE) { return Integer.MAX_VALUE; } - return 30; + return getMaxUserSessions() * 10; + } + + // Get the maximum number of user sessions + public int getMaxUserSessions() { + if (runningEE) { + return Integer.MAX_VALUE; + } + return 3; } } From 7222e992da924c977a64696ef4d0584f711a8b20 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:50:32 +0100 Subject: [PATCH 06/61] Update UserAuthenticationFilter.java --- .../SPDF/config/security/UserAuthenticationFilter.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index 61df11d1..89528c1d 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -71,6 +71,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { sessionPersistentRegistry.getAllSessions(username, false); int userSessions = allSessions.size(); + int maxUserSessions = sessionPersistentRegistry.getMaxUserSessions(); HttpSession session = request.getSession(false); if (session == null) { @@ -79,13 +80,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } String sessionId = session.getId(); - if (allSessions.size() > 2) { + if (userSessions > maxUserSessions) { // Sortiere nach letzter Aktivität – älteste zuerst List sortedSessions = allSessions.stream() .sorted(Comparator.comparing(SessionInformation::getLastRequest)) .collect(Collectors.toList()); - int sessionsToExpire = allSessions.size() - 2; + int sessionsToExpire = userSessions - maxUserSessions; + log.info("Expire {} old sessions", sessionsToExpire); for (int i = 0; i < sessionsToExpire; i++) { SessionInformation oldSession = sortedSessions.get(i); if (!sessionId.equals(oldSession.getSessionId())) { From cc7800a66f95174d1d9363c0c9dad28a68b6f173 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:50:44 +0100 Subject: [PATCH 07/61] Update AccountWebController.java --- .../software/SPDF/controller/web/AccountWebController.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index c49d1872..133bb7a8 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -210,6 +210,8 @@ public class AccountWebController { int activeUsers = 0; int disabledUsers = 0; int maxSessions = sessionPersistentRegistry.getMaxSessions(); + int maxUserSessions = sessionPersistentRegistry.getMaxUserSessions(); + int sessionCount = sessionPersistentRegistry.getAllSessionsNotExpired().size(); while (iterator.hasNext()) { User user = iterator.next(); if (user != null) { @@ -327,6 +329,8 @@ public class AccountWebController { model.addAttribute("activeUsers", activeUsers); model.addAttribute("disabledUsers", disabledUsers); model.addAttribute("maxSessions", maxSessions); + model.addAttribute("maxUserSessions", maxUserSessions); + model.addAttribute("sessionCount", sessionCount); return "addUsers"; } From 495913bc4f1dd70107e2137b3d215311376ceaa3 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:51:13 +0100 Subject: [PATCH 08/61] Update messages_en_GB.properties --- src/main/resources/messages_en_GB.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index aab94537..acffe692 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -231,6 +231,8 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.userSessions=User sessions +adminUserSettings.totalSessions=Total Sessions: database.title=Database Import/Export @@ -1283,7 +1285,7 @@ survey.button=Take Survey survey.dontShowAgain=Don't show again survey.meeting.1=If you're using Stirling PDF at work, we'd love to speak to you. We're offering technical support sessions in exchange for a 15 minute user discovery session. survey.meeting.2=This is a chance to: -survey.meeting.3=Get help with deployment, integrations, or troubleshooting +survey.meeting.3=Get help with deployment, integrations, or troubleshooting survey.meeting.4=Provide direct feedback on performance, edge cases, and feature gaps survey.meeting.5=Help us refine Stirling PDF for real-world enterprise use survey.meeting.6=If you're interested, you can book time with our team directly. (English speaking only) From 3400cd07512089018da8b7ccc0f0e2ad72b85388 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:51:50 +0100 Subject: [PATCH 09/61] Update addUsers.html --- src/main/resources/templates/addUsers.html | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/resources/templates/addUsers.html b/src/main/resources/templates/addUsers.html index b49cb879..8d86ca81 100644 --- a/src/main/resources/templates/addUsers.html +++ b/src/main/resources/templates/addUsers.html @@ -45,7 +45,12 @@ Total Users: Active Users: Disabled Users: - Max Sessions + + Total Sessions: + + + Total Sessions: / +
@@ -70,7 +75,7 @@ Roles Authenticated Last Request - Sessions + User Sessions Actions @@ -82,7 +87,13 @@ - + + + + + + + From 4e67aa52e6654b1e1f92114523b268988a838121 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 00:54:10 +0100 Subject: [PATCH 10/61] Update UserAuthenticationFilter.java --- .../software/SPDF/config/security/UserAuthenticationFilter.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index 89528c1d..4d41a562 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -130,7 +130,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { return; } String requestURI = request.getRequestURI(); - // authentication = SecurityContextHolder.getContext().getAuthentication(); } // Check for API key in the request headers if no authentication exists if (authentication == null || !authentication.isAuthenticated()) { From aaa3739856a0e3c45698d3c37c985827e41f7e79 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Mar 2025 22:33:41 +0100 Subject: [PATCH 11/61] Update CustomHttpSessionListener.java --- .../SPDF/config/security/session/CustomHttpSessionListener.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java index dd0b0102..4fb79d38 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java @@ -42,6 +42,7 @@ public class CustomHttpSessionListener implements HttpSessionListener { } String principalName = UserUtils.getUsernameFromPrincipal(principal); if (principalName == null || "anonymousUser".equals(principalName)) { + log.info("Principal is null or anonymousUser"); return; } log.info("Session created: {}", principalName); @@ -51,5 +52,6 @@ public class CustomHttpSessionListener implements HttpSessionListener { @Override public void sessionDestroyed(HttpSessionEvent se) { sessionPersistentRegistry.expireSession(se.getSession().getId()); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); } } From 1c33c39c57b6c323dd32228acc6b3a8dda7b3a46 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Tue, 25 Mar 2025 12:44:28 +0100 Subject: [PATCH 12/61] Skip anonymousUser if login is enabled --- .../config/security/session/SessionScheduled.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java index e6d52421..5fe82f09 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java @@ -5,6 +5,7 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -18,12 +19,16 @@ import lombok.extern.slf4j.Slf4j; public class SessionScheduled { private final SessionPersistentRegistry sessionPersistentRegistry; + private final boolean loginEnabledValue; - public SessionScheduled(SessionPersistentRegistry sessionPersistentRegistry) { + public SessionScheduled( + SessionPersistentRegistry sessionPersistentRegistry, + @Qualifier("loginEnabled") boolean loginEnabledValue) { this.sessionPersistentRegistry = sessionPersistentRegistry; + this.loginEnabledValue = loginEnabledValue; } - @Scheduled(cron = "0 0/1 * * * ?") // TODO: Change to 5m + @Scheduled(cron = "0 0/1 * * * ?") public void expireSessions() { Instant now = Instant.now(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -31,7 +36,8 @@ public class SessionScheduled { if (principal == null) { continue; } else if (principal instanceof String stringPrincipal) { - if ("anonymousUser".equals(stringPrincipal)) { + // Skip anonymousUser if login is enabled + if ("anonymousUser".equals(stringPrincipal) && loginEnabledValue) { continue; } } From 69b12030d5564fa04421aa653dc575fb7437f72c Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Tue, 25 Mar 2025 18:13:19 +0100 Subject: [PATCH 13/61] AnonymusSession --- .../anonymus/session/AnonymusSessionInfo.java | 48 ++++++++++++ .../session/AnonymusSessionRegistry.java | 75 +++++++++++++++++++ .../session/AnonymusSessionService.java | 29 +++++++ .../AnonymusSessionStatusController.java | 48 ++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java create mode 100644 src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java create mode 100644 src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java create mode 100644 src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java new file mode 100644 index 00000000..47c8a353 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java @@ -0,0 +1,48 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.Date; + +import jakarta.servlet.http.HttpSession; + +public class AnonymusSessionInfo { + private HttpSession session; + private final Date createdAt; + private Date lastRequest; + private Boolean expired; + + public AnonymusSessionInfo( + HttpSession session, Date createdAt, Date lastRequest, Boolean expired) { + this.session = session; + this.createdAt = createdAt; + this.expired = expired; + this.lastRequest = lastRequest; + } + + public void setSession(HttpSession session) { + this.session = session; + } + + public HttpSession getSession() { + return session; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setExpired(Boolean expired) { + this.expired = expired; + } + + public Boolean isExpired() { + return expired; + } + + public void setLastRequest(Date lastRequest) { + this.lastRequest = lastRequest; + } + + public Date getLastRequest() { + return lastRequest; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java new file mode 100644 index 00000000..284f13c0 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java @@ -0,0 +1,75 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.Collection; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class AnonymusSessionRegistry implements HttpSessionListener { + + // Map zur Speicherung der Sessions inkl. Timestamp + private static final Map sessions = new ConcurrentHashMap<>(); + + @Override + public void sessionCreated(HttpSessionEvent event) { + HttpSession session = event.getSession(); + if (session == null) { + log.info("Session ist null"); + return; + } + + System.out.println(""); + System.out.println("Session created with id: " + session.getId()); + System.out.println(""); + + if (sessions.containsKey(session.getId())) { + log.info("Session {} existiert bereits", session.getId()); + return; + } + + // Speichern des anonymousUser-Flags + session.setAttribute("anonymousUser", true); + // Speichern des Erstellungszeitpunkts + Date creationTime = new Date(); + session.setAttribute("creationTimestamp", creationTime); + sessions.put( + session.getId(), + new AnonymusSessionInfo(session, creationTime, creationTime, false)); + + log.info("Session {} erstellt um {}", session.getId(), creationTime); + } + + @Override + public void sessionDestroyed(HttpSessionEvent event) { + HttpSession session = event.getSession(); + if (session == null) { + log.info("Session ist null"); + return; + } + AnonymusSessionInfo sessionsInfo = sessions.get(session.getId()); + if (sessionsInfo == null) { + log.info("Session {} existiert nicht", session.getId()); + return; + } + sessionsInfo.setExpired(true); + log.info("Session {} wurde Expired=TRUE", session.getId()); + } + + public Collection getAllSessions() { + return sessions.values(); + } + + public Collection getAllNonExpiredSessions() { + return sessions.values().stream().filter(info -> !info.isExpired()).toList(); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java new file mode 100644 index 00000000..42584343 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java @@ -0,0 +1,29 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class AnonymusSessionService { + + @Autowired private AnonymusSessionRegistry sessionRegistry; + + @Scheduled(cron = "0 0/1 * * * ?") + public void expireSessions() { + List allNonExpiredSessions = + new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); + if (allNonExpiredSessions.isEmpty()) { + log.info("Keine nicht abgelaufenen Sessions gefunden."); + return; + } else { + log.info("Es gibt {} nicht abgelaufene Sessions", allNonExpiredSessions.size()); + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java new file mode 100644 index 00000000..04b2c6ce --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java @@ -0,0 +1,48 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import lombok.extern.slf4j.Slf4j; + +@RestController +@Slf4j +public class AnonymusSessionStatusController { + + @Autowired private AnonymusSessionRegistry sessionRegistry; + private static final int MAX_SESSIONS = 3; + + @GetMapping("/session/status") + public ResponseEntity getSessionStatus(HttpServletRequest request) { + HttpSession session = request.getSession(false); + List allNonExpiredSessions = + new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); + + for (AnonymusSessionInfo info : allNonExpiredSessions) { + log.info( + "Session ID: {}, Created At: {}, Last Request: {}, Expired: {}", + info.getSession().getId(), + info.getCreatedAt(), + info.getLastRequest(), + info.isExpired()); + } + + if (allNonExpiredSessions.size() > MAX_SESSIONS) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Session ungültig oder abgelaufen"); + } else if (session != null) { + return ResponseEntity.ok("Session gültig: " + session.getId()); + } else { + return ResponseEntity.ok("User has session"); + } + } +} From d9755c965841de7c7afb9a2d2bc70d0fddfea85e Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:04:47 +0100 Subject: [PATCH 14/61] Update AnonymusSessionRegistry.java --- .../session/AnonymusSessionRegistry.java | 72 ++++++++++++++++--- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java index 284f13c0..301f4a94 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java @@ -1,10 +1,15 @@ package stirling.software.SPDF.config.anonymus.session; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Collection; +import java.util.Comparator; import java.util.Date; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import jakarta.servlet.http.HttpSession; @@ -13,9 +18,16 @@ import jakarta.servlet.http.HttpSessionListener; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.SessionsInterface; + @Component @Slf4j -public class AnonymusSessionRegistry implements HttpSessionListener { +public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInterface { + + @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m + private Duration defaultMaxInactiveInterval; + + private static final int MAX_SESSIONS = 1; // Map zur Speicherung der Sessions inkl. Timestamp private static final Map sessions = new ConcurrentHashMap<>(); @@ -42,33 +54,75 @@ public class AnonymusSessionRegistry implements HttpSessionListener { // Speichern des Erstellungszeitpunkts Date creationTime = new Date(); session.setAttribute("creationTimestamp", creationTime); - sessions.put( - session.getId(), - new AnonymusSessionInfo(session, creationTime, creationTime, false)); - log.info("Session {} erstellt um {}", session.getId(), creationTime); + int allNonExpiredSessions = getAllNonExpiredSessions().size(); + + if (allNonExpiredSessions >= MAX_SESSIONS) { + log.info("Maximale Anzahl an Sessions erreicht"); + sessions.put( + session.getId(), + new AnonymusSessionInfo(session, creationTime, creationTime, true)); + } else { + sessions.put( + session.getId(), + new AnonymusSessionInfo(session, creationTime, creationTime, false)); + } } @Override public void sessionDestroyed(HttpSessionEvent event) { HttpSession session = event.getSession(); if (session == null) { - log.info("Session ist null"); return; } AnonymusSessionInfo sessionsInfo = sessions.get(session.getId()); if (sessionsInfo == null) { - log.info("Session {} existiert nicht", session.getId()); return; } - sessionsInfo.setExpired(true); - log.info("Session {} wurde Expired=TRUE", session.getId()); + + Date lastRequest = sessionsInfo.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant now = Instant.now(); + Instant expirationTime = + lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + sessionsInfo.setExpired(true); + session.invalidate(); + log.info("Session {} wurde Expired=TRUE", session.getId()); + } } + @Override + public boolean isSessionValid(String sessionId) { + boolean exists = sessions.containsKey(sessionId); + boolean expired = exists ? sessions.get(sessionId).isExpired() : false; + return exists && !expired; + } + + @Override + public boolean isOldestNonExpiredSession(String sessionId) { + Collection nonExpiredSessions = getAllNonExpiredSessions(); + return nonExpiredSessions.stream() + .min(Comparator.comparing(AnonymusSessionInfo::getLastRequest)) + .map(oldest -> oldest.getSession().getId().equals(sessionId)) + .orElse(false); + } + + @Override + public void updateSessionLastRequest(String sessionId) { + if (sessions.containsKey(sessionId)) { + AnonymusSessionInfo sessionInfo = sessions.get(sessionId); + sessionInfo.setLastRequest(new Date()); + } + } + + @Override public Collection getAllSessions() { return sessions.values(); } + @Override public Collection getAllNonExpiredSessions() { return sessions.values().stream().filter(info -> !info.isExpired()).toList(); } From fe378042f0871ac3ee6c02d47c9f572bc28e10b4 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:05:14 +0100 Subject: [PATCH 15/61] Update AnonymusSessionService.java --- .../session/AnonymusSessionService.java | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java index 42584343..c5b8d907 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java @@ -1,9 +1,14 @@ package stirling.software.SPDF.config.anonymus.session; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Date; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -15,15 +20,28 @@ public class AnonymusSessionService { @Autowired private AnonymusSessionRegistry sessionRegistry; + @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m + private Duration defaultMaxInactiveInterval; + @Scheduled(cron = "0 0/1 * * * ?") public void expireSessions() { + Instant now = Instant.now(); List allNonExpiredSessions = new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); - if (allNonExpiredSessions.isEmpty()) { - log.info("Keine nicht abgelaufenen Sessions gefunden."); - return; - } else { - log.info("Es gibt {} nicht abgelaufene Sessions", allNonExpiredSessions.size()); + for (AnonymusSessionInfo sessionInformation : allNonExpiredSessions) { + Date lastRequest = sessionInformation.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant expirationTime = + lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + log.info( + "SessionID: {} expiration time: {} Current time: {}", + sessionInformation.getSession().getId(), + expirationTime, + now); + sessionInformation.setExpired(true); + } } } } From 5ca84f4aa31d3d8f731bb08d0be2eb4d5d61dc9a Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:06:10 +0100 Subject: [PATCH 16/61] Update AnonymusSessionStatusController.java --- .../AnonymusSessionStatusController.java | 65 ++++++++++++++----- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java index 04b2c6ce..fad15380 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java @@ -1,7 +1,8 @@ package stirling.software.SPDF.config.anonymus.session; import java.util.ArrayList; -import java.util.List; +import java.util.Collection; +import java.util.Date; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -14,35 +15,67 @@ import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.SessionsInterface; + @RestController @Slf4j public class AnonymusSessionStatusController { @Autowired private AnonymusSessionRegistry sessionRegistry; - private static final int MAX_SESSIONS = 3; + @Autowired private SessionsInterface sessionsInterface; + private static final int MAX_SESSIONS = 1; @GetMapping("/session/status") public ResponseEntity getSessionStatus(HttpServletRequest request) { HttpSession session = request.getSession(false); - List allNonExpiredSessions = - new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); - - for (AnonymusSessionInfo info : allNonExpiredSessions) { - log.info( - "Session ID: {}, Created At: {}, Last Request: {}, Expired: {}", - info.getSession().getId(), - info.getCreatedAt(), - info.getLastRequest(), - info.isExpired()); + if (session == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No session found"); } - if (allNonExpiredSessions.size() > MAX_SESSIONS) { + Collection allNonExpiredSessions = + new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); + if (allNonExpiredSessions.isEmpty()) { + allNonExpiredSessions.add( + new AnonymusSessionInfo(session, new Date(), new Date(), false)); + } + + // wenn session expire ist dann UNAUTHORIZED + if (allNonExpiredSessions.stream() + .anyMatch(s -> s.getSession().getId().equals(session.getId()) && s.isExpired())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Session expired"); + } + + // wenn nicht in der Liste dann UNAUTHORIZED + if (allNonExpiredSessions.stream() + .noneMatch(s -> s.getSession().getId().equals(session.getId()))) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No session found"); + } + + if (allNonExpiredSessions.size() > MAX_SESSIONS + && sessionsInterface.isSessionValid(session.getId()) + && sessionsInterface.isOldestNonExpiredSession(session.getId())) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body("Session ungültig oder abgelaufen"); - } else if (session != null) { - return ResponseEntity.ok("Session gültig: " + session.getId()); + } + return ResponseEntity.ok("Session gültig: " + session.getId()); + } + + @GetMapping("/session/expire") + public ResponseEntity expireSession(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + return ResponseEntity.ok("Session invalidated"); } else { - return ResponseEntity.ok("User has session"); + return ResponseEntity.ok("No session to invalidate"); } } + + @GetMapping("/session/expire/all") + public ResponseEntity expireAllSessions() { + sessionRegistry + .getAllNonExpiredSessions() + .forEach(sessionInfo -> sessionInfo.getSession().invalidate()); + return ResponseEntity.ok("All sessions invalidated"); + } } From 8cb44a40a2213891e996a6ae4f3f8663adb53b37 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:07:17 +0100 Subject: [PATCH 17/61] Update EndpointInterceptor.java --- .../SPDF/config/EndpointInterceptor.java | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index 8c60ca90..69222328 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -5,20 +5,67 @@ import org.springframework.web.servlet.HandlerInterceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.interfaces.SessionsInterface; @Component +@Slf4j public class EndpointInterceptor implements HandlerInterceptor { private final EndpointConfiguration endpointConfiguration; + private final SessionsInterface sessionsInterface; - public EndpointInterceptor(EndpointConfiguration endpointConfiguration) { + public EndpointInterceptor( + EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) { this.endpointConfiguration = endpointConfiguration; + this.sessionsInterface = sessionsInterface; } @Override public boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + HttpSession session = request.getSession(false); + if (session == null) { + session = request.getSession(true); + } + + if ("GET".equalsIgnoreCase(request.getMethod())) { + if ("/".equals(request.getRequestURI()) + || "/login".equals(request.getRequestURI()) + || "/home".equals(request.getRequestURI()) + || "/home-legacy".equals(request.getRequestURI()) + || request.getRequestURI().contains("/js/") + || request.getRequestURI().contains("/css/") + || request.getRequestURI().contains("/fonts/") + || request.getRequestURI().contains("/images/") + || request.getRequestURI().contains("/favicon") + || request.getRequestURI().contains("/error") + || request.getRequestURI().contains("/session/status") + || request.getRequestURI().contains("/session/expire") + || request.getRequestURI().contains("/session/expire-all") + || request.getRequestURI().endsWith(".js") + || request.getRequestURI().endsWith(".png") + || request.getRequestURI().endsWith(".webmanifest") + || request.getRequestURI().contains("/files/")) { + return true; + } else { + String sessionId = session != null ? session.getId() : null; + + if (sessionId == null || !sessionsInterface.isSessionValid(sessionId)) { + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + "Max sessions reached for this user. To continue on this device, please close your session in another browser."); + return false; + } else { + sessionsInterface.updateSessionLastRequest(sessionId); + } + } + } + String requestURI = request.getRequestURI(); if (!endpointConfiguration.isEndpointEnabled(requestURI)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled"); From a5fcd2b3d23c519752d07e2059d5f8022cc62e36 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:07:22 +0100 Subject: [PATCH 18/61] Create SessionsInterface.java --- .../config/interfaces/SessionsInterface.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java new file mode 100644 index 00000000..e368046b --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java @@ -0,0 +1,20 @@ +package stirling.software.SPDF.config.interfaces; + +import java.util.Collection; + +import stirling.software.SPDF.config.anonymus.session.AnonymusSessionInfo; + +public interface SessionsInterface { + + default boolean isSessionValid(String sessionId) { + return false; + } + + boolean isOldestNonExpiredSession(String sessionId); + + void updateSessionLastRequest(String sessionId); + + Collection getAllSessions(); + + Collection getAllNonExpiredSessions(); +} From 8e91c49dc499fa249fb777a27335d52a96bcd5ee Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:39:05 +0100 Subject: [PATCH 19/61] Update messages_en_GB.properties --- src/main/resources/messages_en_GB.properties | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index acffe692..006e4e7a 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -233,7 +233,31 @@ adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request adminUserSettings.userSessions=User sessions adminUserSettings.totalSessions=Total Sessions: +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export @@ -711,9 +735,10 @@ sanitizePDF.title=Sanitize PDF sanitizePDF.header=Sanitize a PDF file sanitizePDF.selectText.1=Remove JavaScript actions sanitizePDF.selectText.2=Remove embedded files -sanitizePDF.selectText.3=Remove metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Remove links sanitizePDF.selectText.5=Remove fonts +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanitize PDF From de8cc4f338dfbbd0426b80ba596bb1b2d1f19486 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 12:42:36 +0100 Subject: [PATCH 20/61] Update AccountWebController.java --- .../SPDF/controller/web/AccountWebController.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 133bb7a8..6a5b7344 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.session.SessionInformation; @@ -57,20 +58,23 @@ public class AccountWebController { private final SessionPersistentRegistry sessionPersistentRegistry; // Assuming you have a repository for user operations private final UserRepository userRepository; + private final boolean loginEnabledValue; public AccountWebController( ApplicationProperties applicationProperties, SessionPersistentRegistry sessionPersistentRegistry, - UserRepository userRepository) { + UserRepository userRepository, + @Qualifier("loginEnabled") boolean loginEnabledValue) { this.applicationProperties = applicationProperties; this.sessionPersistentRegistry = sessionPersistentRegistry; this.userRepository = userRepository; + this.loginEnabledValue = loginEnabledValue; } @GetMapping("/login") public String login(HttpServletRequest request, Model model, Authentication authentication) { // If the user is already authenticated, redirect them to the home page. - if (authentication != null && authentication.isAuthenticated()) { + if ((authentication != null && authentication.isAuthenticated()) || !loginEnabledValue) { return "redirect:/"; } @@ -320,7 +324,9 @@ public class AccountWebController { } model.addAttribute("users", sortedUsers); - model.addAttribute("currentUsername", authentication.getName()); + if (authentication != null) { + model.addAttribute("currentUsername", authentication.getName()); + } model.addAttribute("roleDetails", roleDetails); model.addAttribute("userSessions", userSessions); model.addAttribute("userLastRequest", userLastRequest); From c7e65cfd26428ecfae472304ea6eb4bbc8f1a5f5 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 13:19:41 +0100 Subject: [PATCH 21/61] Update AnonymusSessionRegistry.java --- .../config/anonymus/session/AnonymusSessionRegistry.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java index 301f4a94..df919951 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java @@ -36,16 +36,10 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt public void sessionCreated(HttpSessionEvent event) { HttpSession session = event.getSession(); if (session == null) { - log.info("Session ist null"); return; } - System.out.println(""); - System.out.println("Session created with id: " + session.getId()); - System.out.println(""); - if (sessions.containsKey(session.getId())) { - log.info("Session {} existiert bereits", session.getId()); return; } @@ -58,7 +52,6 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt int allNonExpiredSessions = getAllNonExpiredSessions().size(); if (allNonExpiredSessions >= MAX_SESSIONS) { - log.info("Maximale Anzahl an Sessions erreicht"); sessions.put( session.getId(), new AnonymusSessionInfo(session, creationTime, creationTime, true)); From bb5284b2f9e9ae427fdfb663186387475b592b59 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 15:58:59 +0100 Subject: [PATCH 22/61] Update build.gradle --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index b117c64d..36e8eedf 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,8 @@ sourceSets { exclude "stirling/software/SPDF/model/SessionEntity.java" exclude "stirling/software/SPDF/model/User.java" exclude "stirling/software/SPDF/repository/**" + } else { + exclude "stirling/software/SPDF/config/anonymus/**" } if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") { From b6c6a3445cc4a257f79fd0a866ec91a9de835bd4 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 16:34:29 +0100 Subject: [PATCH 23/61] main -> branch --- .github/workflows/scorecards.yml | 2 +- Dockerfile | 17 +- Dockerfile.fat | 17 +- Dockerfile.ultra-lite | 9 +- README.md | 76 ++-- build.gradle | 8 +- exampleYmlFiles/test_cicd.yml | 2 +- scripts/init-without-ocr.sh | 3 + .../software/SPDF/EE/EEAppConfig.java | 73 +++- .../SPDF/EE/KeygenLicenseVerifier.java | 412 ++++++++++++++++-- .../software/SPDF/EE/LicenseKeyChecker.java | 58 ++- .../software/SPDF/config/AppConfig.java | 2 +- .../SPDF/config/ConfigInitializer.java | 45 ++ .../SPDF/config/EndpointConfiguration.java | 14 +- .../SPDF/config/EndpointInspector.java | 1 - .../SPDF/config/EndpointInterceptor.java | 2 + .../SPDF/config/EnterpriseEndpointFilter.java | 38 ++ .../SPDF/controller/api/MergeController.java | 28 +- .../api/SplitPdfBySizeController.java | 151 +++---- .../SPDF/controller/api/UserController.java | 53 ++- .../api/converters/ConvertWebsiteToPDF.java | 10 +- .../api/misc/MetadataController.java | 2 +- .../api/security/SanitizeController.java | 23 +- .../controller/web/AccountWebController.java | 15 +- .../controller/web/MetricsController.java | 144 ++++-- .../SPDF/model/ApplicationProperties.java | 48 ++ .../api/security/SanitizePdfRequest.java | 7 +- .../service/CustomPDFDocumentFactory.java | 139 ++++-- .../SPDF/service/PdfMetadataService.java | 26 +- .../software/SPDF/service/PostHogService.java | 25 +- src/main/resources/messages_ar_AR.properties | 27 +- src/main/resources/messages_az_AZ.properties | 27 +- src/main/resources/messages_bg_BG.properties | 27 +- src/main/resources/messages_ca_CA.properties | 27 +- src/main/resources/messages_cs_CZ.properties | 27 +- src/main/resources/messages_da_DK.properties | 27 +- src/main/resources/messages_de_DE.properties | 27 +- src/main/resources/messages_el_GR.properties | 27 +- src/main/resources/messages_en_GB.properties | 26 -- src/main/resources/messages_en_US.properties | 27 +- src/main/resources/messages_es_ES.properties | 27 +- src/main/resources/messages_eu_ES.properties | 27 +- src/main/resources/messages_fa_IR.properties | 27 +- src/main/resources/messages_fr_FR.properties | 27 +- src/main/resources/messages_ga_IE.properties | 27 +- src/main/resources/messages_hi_IN.properties | 27 +- src/main/resources/messages_hr_HR.properties | 27 +- src/main/resources/messages_hu_HU.properties | 27 +- src/main/resources/messages_id_ID.properties | 27 +- src/main/resources/messages_it_IT.properties | 27 +- src/main/resources/messages_ja_JP.properties | 27 +- src/main/resources/messages_ko_KR.properties | 27 +- src/main/resources/messages_nl_NL.properties | 27 +- src/main/resources/messages_no_NB.properties | 343 ++++++++------- src/main/resources/messages_pl_PL.properties | 27 +- src/main/resources/messages_pt_BR.properties | 27 +- src/main/resources/messages_pt_PT.properties | 27 +- src/main/resources/messages_ro_RO.properties | 27 +- src/main/resources/messages_ru_RU.properties | 27 +- src/main/resources/messages_sk_SK.properties | 27 +- src/main/resources/messages_sl_SI.properties | 27 +- .../resources/messages_sr_LATN_RS.properties | 27 +- src/main/resources/messages_sv_SE.properties | 27 +- src/main/resources/messages_th_TH.properties | 27 +- src/main/resources/messages_tr_TR.properties | 27 +- src/main/resources/messages_uk_UA.properties | 27 +- src/main/resources/messages_vi_VN.properties | 27 +- src/main/resources/messages_zh_BO.properties | 27 +- src/main/resources/messages_zh_CN.properties | 27 +- src/main/resources/messages_zh_TW.properties | 27 +- src/main/resources/settings.yml.template | 19 +- .../resources/static/3rdPartyLicenses.json | 43 ++ src/main/resources/static/css/usage.css | 83 ++++ .../static/js/thirdParty/chart.umd.min.js | 20 + src/main/resources/static/js/usage.js | 363 +++++++++++++++ .../resources/static/pdfjs-legacy/pdf.mjs | 16 +- src/main/resources/templates/account.html | 2 +- src/main/resources/templates/addUsers.html | 308 ------------- .../resources/templates/adminSettings.html | 360 +++++++++++++++ .../templates/security/sanitize-pdf.html | 8 +- src/main/resources/templates/usage.html | 110 +++++ .../converters/ConvertWebsiteToPdfTest.java | 9 +- testing/webpage_urls_full.txt | 1 - 83 files changed, 3313 insertions(+), 874 deletions(-) create mode 100644 src/main/java/stirling/software/SPDF/config/EnterpriseEndpointFilter.java create mode 100644 src/main/resources/static/css/usage.css create mode 100644 src/main/resources/static/js/thirdParty/chart.umd.min.js create mode 100644 src/main/resources/static/js/usage.js delete mode 100644 src/main/resources/templates/addUsers.html create mode 100644 src/main/resources/templates/adminSettings.html create mode 100644 src/main/resources/templates/usage.html diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 37ff7cea..dacb04de 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12 + uses: github/codeql-action/upload-sarif@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13 with: sarif_file: results.sarif diff --git a/Dockerfile b/Dockerfile index 46cae347..a042ae0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,20 +25,16 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, conver # Set Environment Variables ENV DOCKER_ENABLE_SECURITY=false \ VERSION_TAG=$VERSION_TAG \ - JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions \ - -XX:MaxRAMPercentage=75 \ - -XX:InitiatingHeapOccupancyPercent=20 \ - -XX:+G1PeriodicGCInvokesConcurrent \ - -XX:G1PeriodicGCInterval=10000 \ - -XX:+UseStringDeduplication \ - -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_CUSTOM_OPTS="" \ HOME=/home/stirlingpdfuser \ PUID=1000 \ PGID=1000 \ UMASK=022 \ PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ UNO_PATH=/usr/lib/libreoffice/program \ - URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc + URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \ + PATH=$PATH:/opt/venv/bin # JDK for app @@ -77,9 +73,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a py3-pillow@testing \ py3-pdf2image@testing && \ python3 -m venv /opt/venv && \ - export PATH="/opt/venv/bin:$PATH" && \ - pip install --upgrade pip && \ - pip install --no-cache-dir --upgrade unoserver weasyprint && \ + /opt/venv/bin/pip install --upgrade pip && \ + /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ diff --git a/Dockerfile.fat b/Dockerfile.fat index 8a4d55d8..cb02a1cd 100644 --- a/Dockerfile.fat +++ b/Dockerfile.fat @@ -32,13 +32,8 @@ ARG VERSION_TAG # Set Environment Variables ENV DOCKER_ENABLE_SECURITY=false \ VERSION_TAG=$VERSION_TAG \ - JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions \ - -XX:MaxRAMPercentage=75 \ - -XX:InitiatingHeapOccupancyPercent=20 \ - -XX:+G1PeriodicGCInvokesConcurrent \ - -XX:G1PeriodicGCInterval=10000 \ - -XX:+UseStringDeduplication \ - -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_CUSTOM_OPTS="" \ HOME=/home/stirlingpdfuser \ PUID=1000 \ PGID=1000 \ @@ -47,7 +42,8 @@ ENV DOCKER_ENABLE_SECURITY=false \ INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \ PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ UNO_PATH=/usr/lib/libreoffice/program \ - URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc + URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \ + PATH=$PATH:/opt/venv/bin # JDK for app @@ -87,9 +83,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a py3-pillow@testing \ py3-pdf2image@testing && \ python3 -m venv /opt/venv && \ - export PATH="/opt/venv/bin:$PATH" && \ - pip install --upgrade pip && \ - pip install --no-cache-dir --upgrade unoserver weasyprint && \ + /opt/venv/bin/pip install --upgrade pip && \ + /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite index 8ebc1a4a..0ea37f70 100644 --- a/Dockerfile.ultra-lite +++ b/Dockerfile.ultra-lite @@ -7,13 +7,8 @@ ARG VERSION_TAG ENV DOCKER_ENABLE_SECURITY=false \ HOME=/home/stirlingpdfuser \ VERSION_TAG=$VERSION_TAG \ - JAVA_TOOL_OPTIONS="-XX:+UnlockExperimentalVMOptions \ - -XX:MaxRAMPercentage=75 \ - -XX:InitiatingHeapOccupancyPercent=20 \ - -XX:+G1PeriodicGCInvokesConcurrent \ - -XX:G1PeriodicGCInterval=10000 \ - -XX:+UseStringDeduplication \ - -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_CUSTOM_OPTS="" \ PUID=1000 \ PGID=1000 \ UMASK=022 diff --git a/README.md b/README.md index 423532fe..3c0e59f7 100644 --- a/README.md +++ b/README.md @@ -116,46 +116,46 @@ Stirling-PDF currently supports 39 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![88%](https://geps.dev/progress/88) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![87%](https://geps.dev/progress/87) | -| Basque (Euskara) (eu_ES) | ![51%](https://geps.dev/progress/51) | -| Bulgarian (Български) (bg_BG) | ![98%](https://geps.dev/progress/98) | -| Catalan (Català) (ca_CA) | ![94%](https://geps.dev/progress/94) | -| Croatian (Hrvatski) (hr_HR) | ![85%](https://geps.dev/progress/85) | -| Czech (Česky) (cs_CZ) | ![96%](https://geps.dev/progress/96) | -| Danish (Dansk) (da_DK) | ![84%](https://geps.dev/progress/84) | -| Dutch (Nederlands) (nl_NL) | ![83%](https://geps.dev/progress/83) | +| Arabic (العربية) (ar_AR) | ![86%](https://geps.dev/progress/86) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![85%](https://geps.dev/progress/85) | +| Basque (Euskara) (eu_ES) | ![49%](https://geps.dev/progress/49) | +| Bulgarian (Български) (bg_BG) | ![95%](https://geps.dev/progress/95) | +| Catalan (Català) (ca_CA) | ![92%](https://geps.dev/progress/92) | +| Croatian (Hrvatski) (hr_HR) | ![83%](https://geps.dev/progress/83) | +| Czech (Česky) (cs_CZ) | ![94%](https://geps.dev/progress/94) | +| Danish (Dansk) (da_DK) | ![82%](https://geps.dev/progress/82) | +| Dutch (Nederlands) (nl_NL) | ![81%](https://geps.dev/progress/81) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![97%](https://geps.dev/progress/97) | -| German (Deutsch) (de_DE) | ![99%](https://geps.dev/progress/99) | -| Greek (Ελληνικά) (el_GR) | ![96%](https://geps.dev/progress/96) | -| Hindi (हिंदी) (hi_IN) | ![96%](https://geps.dev/progress/96) | -| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![85%](https://geps.dev/progress/85) | -| Irish (Gaeilge) (ga_IE) | ![96%](https://geps.dev/progress/96) | -| Italian (Italiano) (it_IT) | ![99%](https://geps.dev/progress/99) | -| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) | -| Korean (한국어) (ko_KR) | ![97%](https://geps.dev/progress/97) | -| Norwegian (Norsk) (no_NB) | ![77%](https://geps.dev/progress/77) | -| Persian (فارسی) (fa_IR) | ![92%](https://geps.dev/progress/92) | -| Polish (Polski) (pl_PL) | ![84%](https://geps.dev/progress/84) | -| Portuguese (Português) (pt_PT) | ![96%](https://geps.dev/progress/96) | -| Portuguese Brazilian (Português) (pt_BR) | ![98%](https://geps.dev/progress/98) | -| Romanian (Română) (ro_RO) | ![79%](https://geps.dev/progress/79) | -| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![63%](https://geps.dev/progress/63) | -| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) | -| Slovakian (Slovensky) (sk_SK) | ![72%](https://geps.dev/progress/72) | -| Slovenian (Slovenščina) (sl_SI) | ![95%](https://geps.dev/progress/95) | -| Spanish (Español) (es_ES) | ![98%](https://geps.dev/progress/98) | -| Swedish (Svenska) (sv_SE) | ![92%](https://geps.dev/progress/92) | -| Thai (ไทย) (th_TH) | ![84%](https://geps.dev/progress/84) | -| Tibetan (བོད་ཡིག་) (zh_BO) | ![93%](https://geps.dev/progress/93) | -| Traditional Chinese (繁體中文) (zh_TW) | ![99%](https://geps.dev/progress/99) | -| Turkish (Türkçe) (tr_TR) | ![81%](https://geps.dev/progress/81) | -| Ukrainian (Українська) (uk_UA) | ![99%](https://geps.dev/progress/99) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![78%](https://geps.dev/progress/78) | +| French (Français) (fr_FR) | ![94%](https://geps.dev/progress/94) | +| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) | +| Greek (Ελληνικά) (el_GR) | ![94%](https://geps.dev/progress/94) | +| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) | +| Hungarian (Magyar) (hu_HU) | ![91%](https://geps.dev/progress/91) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![83%](https://geps.dev/progress/83) | +| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) | +| Italian (Italiano) (it_IT) | ![96%](https://geps.dev/progress/96) | +| Japanese (日本語) (ja_JP) | ![91%](https://geps.dev/progress/91) | +| Korean (한국어) (ko_KR) | ![95%](https://geps.dev/progress/95) | +| Norwegian (Norsk) (no_NB) | ![89%](https://geps.dev/progress/89) | +| Persian (فارسی) (fa_IR) | ![90%](https://geps.dev/progress/90) | +| Polish (Polski) (pl_PL) | ![82%](https://geps.dev/progress/82) | +| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) | +| Portuguese Brazilian (Português) (pt_BR) | ![96%](https://geps.dev/progress/96) | +| Romanian (Română) (ro_RO) | ![77%](https://geps.dev/progress/77) | +| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![62%](https://geps.dev/progress/62) | +| Simplified Chinese (简体中文) (zh_CN) | ![95%](https://geps.dev/progress/95) | +| Slovakian (Slovensky) (sk_SK) | ![71%](https://geps.dev/progress/71) | +| Slovenian (Slovenščina) (sl_SI) | ![93%](https://geps.dev/progress/93) | +| Spanish (Español) (es_ES) | ![96%](https://geps.dev/progress/96) | +| Swedish (Svenska) (sv_SE) | ![89%](https://geps.dev/progress/89) | +| Thai (ไทย) (th_TH) | ![82%](https://geps.dev/progress/82) | +| Tibetan (བོད་ཡིག་) (zh_BO) | ![91%](https://geps.dev/progress/91) | +| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) | +| Turkish (Türkçe) (tr_TR) | ![79%](https://geps.dev/progress/79) | +| Ukrainian (Українська) (uk_UA) | ![97%](https://geps.dev/progress/97) | +| Vietnamese (Tiếng Việt) (vi_VN) | ![76%](https://geps.dev/progress/76) | ## Stirling PDF Enterprise diff --git a/build.gradle b/build.gradle index 36e8eedf..d3e0d430 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { } group = "stirling.software" -version = "0.44.3" +version = "0.45.0" java { // 17 is lowest but we support and recommend 21 @@ -330,9 +330,13 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion" implementation 'com.posthog.java:posthog:1.2.0' implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' - + if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") { + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion" implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE" implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion" diff --git a/exampleYmlFiles/test_cicd.yml b/exampleYmlFiles/test_cicd.yml index cc490c71..eebb50f2 100644 --- a/exampleYmlFiles/test_cicd.yml +++ b/exampleYmlFiles/test_cicd.yml @@ -1,7 +1,7 @@ services: stirling-pdf: container_name: Stirling-PDF-Security-Fat-with-login - image: stirlingtools/stirling-pdf:latest-fat + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-fat deploy: resources: limits: diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index 49218f76..934c995a 100644 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -1,5 +1,8 @@ #!/bin/bash +export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}" +echo "running with JAVA_TOOL_OPTIONS ${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}" + # Update the user and group IDs as per environment variables if [ ! -z "$PUID" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then usermod -o -u "$PUID" stirlingpdfuser || true diff --git a/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java b/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java index 67cb8a91..b651b853 100644 --- a/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java +++ b/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java @@ -8,6 +8,8 @@ import org.springframework.core.annotation.Order; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.ApplicationProperties; +import stirling.software.SPDF.model.ApplicationProperties.EnterpriseEdition; +import stirling.software.SPDF.model.ApplicationProperties.Premium; @Configuration @Order(Ordered.HIGHEST_PRECEDENCE) @@ -22,6 +24,7 @@ public class EEAppConfig { ApplicationProperties applicationProperties, LicenseKeyChecker licenseKeyChecker) { this.applicationProperties = applicationProperties; this.licenseKeyChecker = licenseKeyChecker; + migrateEnterpriseSettingsToPremium(this.applicationProperties); } @Bean(name = "runningEE") @@ -31,6 +34,74 @@ public class EEAppConfig { @Bean(name = "SSOAutoLogin") public boolean ssoAutoLogin() { - return applicationProperties.getEnterpriseEdition().isSsoAutoLogin(); + return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin(); + } + + // TODO: Remove post migration + public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) { + EnterpriseEdition enterpriseEdition = applicationProperties.getEnterpriseEdition(); + Premium premium = applicationProperties.getPremium(); + + // Only proceed if both objects exist + if (enterpriseEdition == null || premium == null) { + return; + } + + // Copy the license key if it's set in enterprise but not in premium + if (premium.getKey() == null + || premium.getKey().equals("00000000-0000-0000-0000-000000000000")) { + if (enterpriseEdition.getKey() != null + && !enterpriseEdition.getKey().equals("00000000-0000-0000-0000-000000000000")) { + premium.setKey(enterpriseEdition.getKey()); + } + } + + // Copy enabled state if enterprise is enabled but premium is not + if (!premium.isEnabled() && enterpriseEdition.isEnabled()) { + premium.setEnabled(true); + } + + // Copy SSO auto login setting + if (!premium.getProFeatures().isSsoAutoLogin() && enterpriseEdition.isSsoAutoLogin()) { + premium.getProFeatures().setSsoAutoLogin(true); + } + + // Copy CustomMetadata settings + Premium.ProFeatures.CustomMetadata premiumMetadata = + premium.getProFeatures().getCustomMetadata(); + EnterpriseEdition.CustomMetadata enterpriseMetadata = enterpriseEdition.getCustomMetadata(); + + if (enterpriseMetadata != null && premiumMetadata != null) { + // Copy autoUpdateMetadata setting + if (!premiumMetadata.isAutoUpdateMetadata() + && enterpriseMetadata.isAutoUpdateMetadata()) { + premiumMetadata.setAutoUpdateMetadata(true); + } + + // Copy author if not set in premium but set in enterprise + if ((premiumMetadata.getAuthor() == null + || premiumMetadata.getAuthor().trim().isEmpty() + || "username".equals(premiumMetadata.getAuthor())) + && enterpriseMetadata.getAuthor() != null + && !enterpriseMetadata.getAuthor().trim().isEmpty()) { + premiumMetadata.setAuthor(enterpriseMetadata.getAuthor()); + } + + // Copy creator if not set in premium but set in enterprise and different from default + if ((premiumMetadata.getCreator() == null + || "Stirling-PDF".equals(premiumMetadata.getCreator())) + && enterpriseMetadata.getCreator() != null + && !"Stirling-PDF".equals(enterpriseMetadata.getCreator())) { + premiumMetadata.setCreator(enterpriseMetadata.getCreator()); + } + + // Copy producer if not set in premium but set in enterprise and different from default + if ((premiumMetadata.getProducer() == null + || "Stirling-PDF".equals(premiumMetadata.getProducer())) + && enterpriseMetadata.getProducer() != null + && !"Stirling-PDF".equals(enterpriseMetadata.getProducer())) { + premiumMetadata.setProducer(enterpriseMetadata.getProducer()); + } + } } } diff --git a/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java b/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java index ca481532..28e0c7e7 100644 --- a/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java +++ b/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java @@ -4,12 +4,17 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Base64; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.bouncycastle.util.encoders.Hex; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.posthog.java.shaded.org.json.JSONException; import com.posthog.java.shaded.org.json.JSONObject; import lombok.extern.slf4j.Slf4j; @@ -20,11 +25,19 @@ import stirling.software.SPDF.utils.GeneralUtils; @Service @Slf4j public class KeygenLicenseVerifier { - // todo: place in config files? + // License verification configuration private static final String ACCOUNT_ID = "e5430f69-e834-4ae4-befd-b602aae5f372"; private static final String BASE_URL = "https://api.keygen.sh/v1/accounts"; - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String PUBLIC_KEY = + "9fbc0d78593dcfcf03c945146edd60083bf5fae77dbc08aaa3935f03ce94a58d"; + + private static final String CERT_PREFIX = "-----BEGIN LICENSE FILE-----"; + private static final String CERT_SUFFIX = "-----END LICENSE FILE-----"; + + private static final String JWT_PREFIX = "key/"; + + private static final ObjectMapper objectMapper = new ObjectMapper(); private final ApplicationProperties applicationProperties; @Autowired @@ -32,9 +45,367 @@ public class KeygenLicenseVerifier { this.applicationProperties = applicationProperties; } - public boolean verifyLicense(String licenseKey) { + public boolean verifyLicense(String licenseKeyOrCert) { + if (isCertificateLicense(licenseKeyOrCert)) { + log.info("Detected certificate-based license. Processing..."); + return verifyCertificateLicense(licenseKeyOrCert); + } else if (isJWTLicense(licenseKeyOrCert)) { + log.info("Detected JWT-style license key. Processing..."); + return verifyJWTLicense(licenseKeyOrCert); + } else { + log.info("Detected standard license key. Processing..."); + return verifyStandardLicense(licenseKeyOrCert); + } + } + + private boolean isCertificateLicense(String license) { + return license != null && license.trim().startsWith(CERT_PREFIX); + } + + private boolean isJWTLicense(String license) { + return license != null && license.trim().startsWith(JWT_PREFIX); + } + + private boolean verifyCertificateLicense(String licenseFile) { try { - log.info("Checking license key"); + log.info("Verifying certificate-based license"); + + String encodedPayload = licenseFile; + // Remove the header + encodedPayload = encodedPayload.replace(CERT_PREFIX, ""); + // Remove the footer + encodedPayload = encodedPayload.replace(CERT_SUFFIX, ""); + // Remove all newlines + encodedPayload = encodedPayload.replaceAll("\\r?\\n", ""); + + byte[] payloadBytes = Base64.getDecoder().decode(encodedPayload); + String payload = new String(payloadBytes); + + log.info("Decoded certificate payload: {}", payload); + + String encryptedData = ""; + String encodedSignature = ""; + String algorithm = ""; + + try { + JSONObject attrs = new JSONObject(payload); + encryptedData = (String) attrs.get("enc"); + encodedSignature = (String) attrs.get("sig"); + algorithm = (String) attrs.get("alg"); + + log.info("Certificate algorithm: {}", algorithm); + } catch (JSONException e) { + log.error("Failed to parse license file: {}", e.getMessage()); + return false; + } + + // Verify license file algorithm + if (!algorithm.equals("base64+ed25519")) { + log.error( + "Unsupported algorithm: {}. Only base64+ed25519 is supported.", algorithm); + return false; + } + + // Verify signature + boolean isSignatureValid = verifyEd25519Signature(encryptedData, encodedSignature); + if (!isSignatureValid) { + log.error("License file signature is invalid"); + return false; + } + + log.info("License file signature is valid"); + + // Decode the base64 data + String decodedData; + try { + decodedData = new String(Base64.getDecoder().decode(encryptedData)); + } catch (IllegalArgumentException e) { + log.error("Failed to decode license data: {}", e.getMessage()); + return false; + } + + // Process the certificate data + boolean isValid = processCertificateData(decodedData); + + return isValid; + } catch (Exception e) { + log.error("Error verifying certificate license: {}", e.getMessage(), e); + return false; + } + } + + private boolean verifyEd25519Signature(String encryptedData, String encodedSignature) { + try { + log.info("Signature to verify: {}", encodedSignature); + log.info("Public key being used: {}", PUBLIC_KEY); + + byte[] signatureBytes = Base64.getDecoder().decode(encodedSignature); + + // Create the signing data format - prefix with "license/" + String signingData = String.format("license/%s", encryptedData); + byte[] signingDataBytes = signingData.getBytes(); + + log.info("Signing data length: {} bytes", signingDataBytes.length); + + byte[] publicKeyBytes = Hex.decode(PUBLIC_KEY); + + Ed25519PublicKeyParameters verifierParams = + new Ed25519PublicKeyParameters(publicKeyBytes, 0); + Ed25519Signer verifier = new Ed25519Signer(); + + verifier.init(false, verifierParams); + verifier.update(signingDataBytes, 0, signingDataBytes.length); + + // Verify the signature + boolean result = verifier.verifySignature(signatureBytes); + if (!result) { + log.error("Signature verification failed with standard public key"); + } + + return result; + } catch (Exception e) { + log.error("Error verifying Ed25519 signature: {}", e.getMessage(), e); + return false; + } + } + + private boolean processCertificateData(String certData) { + try { + log.info("Processing certificate data: {}", certData); + + JSONObject licenseData = new JSONObject(certData); + JSONObject metaObj = licenseData.optJSONObject("meta"); + if (metaObj != null) { + String issuedStr = metaObj.optString("issued", null); + String expiryStr = metaObj.optString("expiry", null); + + if (issuedStr != null && expiryStr != null) { + java.time.Instant issued = java.time.Instant.parse(issuedStr); + java.time.Instant expiry = java.time.Instant.parse(expiryStr); + java.time.Instant now = java.time.Instant.now(); + + if (issued.isAfter(now)) { + log.error( + "License file issued date is in the future. Please adjust system time or request a new license"); + return false; + } + + // Check if the license file has expired + if (expiry.isBefore(now)) { + log.error("License file has expired on {}", expiryStr); + return false; + } + + log.info("License file valid until {}", expiryStr); + } + } + + // Get the main license data + JSONObject dataObj = licenseData.optJSONObject("data"); + if (dataObj == null) { + log.error("No data object found in certificate"); + return false; + } + + // Extract license or machine information + JSONObject attributesObj = dataObj.optJSONObject("attributes"); + if (attributesObj != null) { + log.info("Found attributes in certificate data"); + + // Extract metadata + JSONObject metadataObj = attributesObj.optJSONObject("metadata"); + if (metadataObj != null) { + int users = metadataObj.optInt("users", 0); + if (users > 0) { + applicationProperties.getPremium().setMaxUsers(users); + log.info("License allows for {} users", users); + } + } + + // Check maxUsers directly in attributes if present from policy definition + // if (attributesObj.has("maxUsers")) { + // int maxUsers = attributesObj.optInt("maxUsers", 0); + // if (maxUsers > 0) { + // applicationProperties.getPremium().setMaxUsers(maxUsers); + // log.info("License directly specifies {} max users", + // maxUsers); + // } + // } + + // Check license status if available + String status = attributesObj.optString("status", null); + if (status != null + && !status.equals("ACTIVE") + && !status.equals("EXPIRING")) { // Accept "EXPIRING" status as valid + log.error("License status is not active: {}", status); + return false; + } + } + + return true; + } catch (Exception e) { + log.error("Error processing certificate data: {}", e.getMessage(), e); + return false; + } + } + + private boolean verifyJWTLicense(String licenseKey) { + try { + log.info("Verifying ED25519_SIGN format license key"); + + // Remove the "key/" prefix + String licenseData = licenseKey.substring(JWT_PREFIX.length()); + + // Split into payload and signature + String[] parts = licenseData.split("\\.", 2); + if (parts.length != 2) { + log.error( + "Invalid ED25519_SIGN license format. Expected format: key/payload.signature"); + return false; + } + + String encodedPayload = parts[0]; + String encodedSignature = parts[1]; + + // Verify signature + boolean isSignatureValid = verifyJWTSignature(encodedPayload, encodedSignature); + if (!isSignatureValid) { + log.error("ED25519_SIGN license signature is invalid"); + return false; + } + + log.info("ED25519_SIGN license signature is valid"); + + // Decode and process payload - first convert from URL-safe base64 if needed + String base64Payload = encodedPayload.replace('-', '+').replace('_', '/'); + byte[] payloadBytes = Base64.getDecoder().decode(base64Payload); + String payload = new String(payloadBytes); + + // Process the license payload + boolean isValid = processJWTLicensePayload(payload); + + return isValid; + } catch (Exception e) { + log.error("Error verifying ED25519_SIGN license: {}", e.getMessage()); + return false; + } + } + + private boolean verifyJWTSignature(String encodedPayload, String encodedSignature) { + try { + // Decode base64 signature + byte[] signatureBytes = + Base64.getDecoder() + .decode(encodedSignature.replace('-', '+').replace('_', '/')); + + // For ED25519_SIGN format, the signing data is "key/" + encodedPayload + String signingData = String.format("key/%s", encodedPayload); + byte[] dataBytes = signingData.getBytes(); + + byte[] publicKeyBytes = Hex.decode(PUBLIC_KEY); + Ed25519PublicKeyParameters verifierParams = + new Ed25519PublicKeyParameters(publicKeyBytes, 0); + Ed25519Signer verifier = new Ed25519Signer(); + + verifier.init(false, verifierParams); + verifier.update(dataBytes, 0, dataBytes.length); + + // Verify the signature + return verifier.verifySignature(signatureBytes); + } catch (Exception e) { + log.error("Error verifying JWT signature: {}", e.getMessage()); + return false; + } + } + + private boolean processJWTLicensePayload(String payload) { + try { + log.info("Processing license payload: {}", payload); + + JSONObject licenseData = new JSONObject(payload); + + JSONObject licenseObj = licenseData.optJSONObject("license"); + if (licenseObj == null) { + String id = licenseData.optString("id", null); + if (id != null) { + log.info("Found license ID: {}", id); + licenseObj = licenseData; // Use the root object as the license object + } else { + log.error("License data not found in payload"); + return false; + } + } + + String licenseId = licenseObj.optString("id", "unknown"); + log.info("Processing license with ID: {}", licenseId); + + // Check expiry date + String expiryStr = licenseObj.optString("expiry", null); + if (expiryStr != null && !expiryStr.equals("null")) { + java.time.Instant expiry = java.time.Instant.parse(expiryStr); + java.time.Instant now = java.time.Instant.now(); + + if (now.isAfter(expiry)) { + log.error("License has expired on {}", expiryStr); + return false; + } + + log.info("License valid until {}", expiryStr); + } else { + log.info("License has no expiration date"); + } + + // Extract account, product, policy info + JSONObject accountObj = licenseData.optJSONObject("account"); + if (accountObj != null) { + String accountId = accountObj.optString("id", "unknown"); + log.info("License belongs to account: {}", accountId); + + // Verify this matches your expected account ID + if (!ACCOUNT_ID.equals(accountId)) { + log.warn("License account ID does not match expected account ID"); + // You might want to fail verification here depending on your requirements + } + } + + // Extract policy information if available + JSONObject policyObj = licenseData.optJSONObject("policy"); + if (policyObj != null) { + String policyId = policyObj.optString("id", "unknown"); + log.info("License uses policy: {}", policyId); + + // Extract max users from policy if available (customize based on your policy + // structure) + int users = policyObj.optInt("users", 0); + if (users > 0) { + applicationProperties.getPremium().setMaxUsers(users); + log.info("License allows for {} users", users); + } else { + // Try to get users from metadata if present + Object metadataObj = policyObj.opt("metadata"); + if (metadataObj instanceof JSONObject) { + JSONObject metadata = (JSONObject) metadataObj; + users = metadata.optInt("users", 1); + applicationProperties.getPremium().setMaxUsers(users); + log.info("License allows for {} users (from metadata)", users); + } else { + // Default value + applicationProperties.getPremium().setMaxUsers(1); + log.info("Using default of 1 user for license"); + } + } + } + + return true; + } catch (Exception e) { + log.error("Error processing license payload: {}", e.getMessage(), e); + return false; + } + } + + private boolean verifyStandardLicense(String licenseKey) { + try { + log.info("Checking standard license key"); String machineFingerprint = generateMachineFingerprint(); // First, try to validate the license @@ -44,7 +415,7 @@ public class KeygenLicenseVerifier { String licenseId = validationResponse.path("data").path("id").asText(); if (!isValid) { String code = validationResponse.path("meta").path("code").asText(); - log.debug(code); + log.info(code); if ("NO_MACHINE".equals(code) || "NO_MACHINES".equals(code) || "FINGERPRINT_SCOPE_MISMATCH".equals(code)) { @@ -69,7 +440,7 @@ public class KeygenLicenseVerifier { return false; } catch (Exception e) { - log.error("Error verifying license: {}", e.getMessage()); + log.error("Error verifying standard license: {}", e.getMessage()); return false; } } @@ -96,7 +467,7 @@ public class KeygenLicenseVerifier { .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - log.debug("ValidateLicenseResponse body: {}", response.body()); + log.info("ValidateLicenseResponse body: {}", response.body()); JsonNode jsonResponse = objectMapper.readTree(response.body()); if (response.statusCode() == 200) { JsonNode metaNode = jsonResponse.path("meta"); @@ -105,9 +476,9 @@ public class KeygenLicenseVerifier { String detail = metaNode.path("detail").asText(); String code = metaNode.path("code").asText(); - log.debug("License validity: " + isValid); - log.debug("Validation detail: " + detail); - log.debug("Validation code: " + code); + log.info("License validity: " + isValid); + log.info("Validation detail: " + detail); + log.info("Validation code: " + code); int users = jsonResponse @@ -116,7 +487,7 @@ public class KeygenLicenseVerifier { .path("metadata") .path("users") .asInt(0); - applicationProperties.getEnterpriseEdition().setMaxUsers(users); + applicationProperties.getPremium().setMaxUsers(users); log.info(applicationProperties.toString()); } else { @@ -148,13 +519,8 @@ public class KeygenLicenseVerifier { .put("fingerprint", machineFingerprint) .put( "platform", - System.getProperty( - "os.name")) // Added - // platform - // parameter - .put( - "name", - hostname)) // Added name parameter + System.getProperty("os.name")) + .put("name", hostname)) .put( "relationships", new JSONObject() @@ -176,16 +542,12 @@ public class KeygenLicenseVerifier { .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines")) .header("Content-Type", "application/vnd.api+json") .header("Accept", "application/vnd.api+json") - .header( - "Authorization", - "License " + licenseKey) // Keep the license key authentication - .POST( - HttpRequest.BodyPublishers.ofString( - body.toString())) // Send the JSON body + .header("Authorization", "License " + licenseKey) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - log.debug("activateMachine Response body: " + response.body()); + log.info("activateMachine Response body: " + response.body()); if (response.statusCode() == 201) { log.info("Machine activated successfully"); return true; diff --git a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java index f87c8a11..d2935112 100644 --- a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java +++ b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java @@ -1,6 +1,9 @@ package stirling.software.SPDF.EE; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; @@ -15,11 +18,13 @@ import stirling.software.SPDF.utils.GeneralUtils; @Slf4j public class LicenseKeyChecker { + private static final String FILE_PREFIX = "file:"; + private final KeygenLicenseVerifier licenseService; private final ApplicationProperties applicationProperties; - private boolean enterpriseEnabledResult = false; + private boolean premiumEnabledResult = false; @Autowired public LicenseKeyChecker( @@ -35,27 +40,58 @@ public class LicenseKeyChecker { } private void checkLicense() { - if (!applicationProperties.getEnterpriseEdition().isEnabled()) { - enterpriseEnabledResult = false; + if (!applicationProperties.getPremium().isEnabled()) { + premiumEnabledResult = false; } else { - enterpriseEnabledResult = - licenseService.verifyLicense( - applicationProperties.getEnterpriseEdition().getKey()); - if (enterpriseEnabledResult) { - log.info("License key is valid."); + String licenseKey = getLicenseKeyContent(applicationProperties.getPremium().getKey()); + if (licenseKey != null) { + premiumEnabledResult = licenseService.verifyLicense(licenseKey); + if (premiumEnabledResult) { + log.info("License key is valid."); + } else { + log.info("License key is invalid."); + } } else { - log.info("License key is invalid."); + log.error("Failed to obtain license key content."); + premiumEnabledResult = false; } } } + private String getLicenseKeyContent(String keyOrFilePath) { + if (keyOrFilePath == null || keyOrFilePath.trim().isEmpty()) { + log.error("License key is not specified"); + return null; + } + + // Check if it's a file reference + if (keyOrFilePath.startsWith(FILE_PREFIX)) { + String filePath = keyOrFilePath.substring(FILE_PREFIX.length()); + try { + Path path = Paths.get(filePath); + if (!Files.exists(path)) { + log.error("License file does not exist: {}", filePath); + return null; + } + log.info("Reading license from file: {}", filePath); + return Files.readString(path); + } catch (IOException e) { + log.error("Failed to read license file: {}", e.getMessage()); + return null; + } + } + + // It's a direct license key + return keyOrFilePath; + } + public void updateLicenseKey(String newKey) throws IOException { - applicationProperties.getEnterpriseEdition().setKey(newKey); + applicationProperties.getPremium().setKey(newKey); GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); checkLicense(); } public boolean getEnterpriseEnabledResult() { - return enterpriseEnabledResult; + return premiumEnabledResult; } } diff --git a/src/main/java/stirling/software/SPDF/config/AppConfig.java b/src/main/java/stirling/software/SPDF/config/AppConfig.java index 46c58036..f741a05a 100644 --- a/src/main/java/stirling/software/SPDF/config/AppConfig.java +++ b/src/main/java/stirling/software/SPDF/config/AppConfig.java @@ -182,7 +182,7 @@ public class AppConfig { @Bean(name = "analyticsEnabled") @Scope("request") public boolean analyticsEnabled() { - if (applicationProperties.getEnterpriseEdition().isEnabled()) return true; + if (applicationProperties.getPremium().isEnabled()) return true; return applicationProperties.getSystem().isAnalyticsEnabled(); } diff --git a/src/main/java/stirling/software/SPDF/config/ConfigInitializer.java b/src/main/java/stirling/software/SPDF/config/ConfigInitializer.java index 119fc92b..95584ef1 100644 --- a/src/main/java/stirling/software/SPDF/config/ConfigInitializer.java +++ b/src/main/java/stirling/software/SPDF/config/ConfigInitializer.java @@ -9,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.List; import lombok.extern.slf4j.Slf4j; @@ -56,6 +57,8 @@ public class ConfigInitializer { YamlHelper settingsTemplateFile = new YamlHelper(tempTemplatePath); YamlHelper settingsFile = new YamlHelper(settingTempPath); + migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile); + boolean changesMade = settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile); if (changesMade) { @@ -76,4 +79,46 @@ public class ConfigInitializer { log.info("Created custom_settings file: {}", customSettingsPath.toString()); } } + + // TODO: Remove post migration + private void migrateEnterpriseEditionToPremium(YamlHelper yaml, YamlHelper template) { + if (yaml.getValueByExactKeyPath("enterpriseEdition", "enabled") != null) { + template.updateValue( + List.of("premium", "enabled"), + yaml.getValueByExactKeyPath("enterpriseEdition", "enabled")); + } + if (yaml.getValueByExactKeyPath("enterpriseEdition", "key") != null) { + template.updateValue( + List.of("premium", "key"), + yaml.getValueByExactKeyPath("enterpriseEdition", "key")); + } + if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) { + template.updateValue( + List.of("premium", "proFeatures", "SSOAutoLogin"), + yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin")); + } + if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata") + != null) { + template.updateValue( + List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"), + yaml.getValueByExactKeyPath( + "enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")); + } + if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) { + template.updateValue( + List.of("premium", "proFeatures", "CustomMetadata", "author"), + yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author")); + } + if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) { + template.updateValue( + List.of("premium", "proFeatures", "CustomMetadata", "creator"), + yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator")); + } + if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer") + != null) { + template.updateValue( + List.of("premium", "proFeatures", "CustomMetadata", "producer"), + yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")); + } + } } diff --git a/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 5b4eb238..9c2d2654 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; @@ -22,10 +23,14 @@ public class EndpointConfiguration { private final ApplicationProperties applicationProperties; private Map endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); + private final boolean runningEE; @Autowired - public EndpointConfiguration(ApplicationProperties applicationProperties) { + public EndpointConfiguration( + ApplicationProperties applicationProperties, + @Qualifier("runningEE") boolean runningEE) { this.applicationProperties = applicationProperties; + this.runningEE = runningEE; init(); processEnvironmentConfigs(); } @@ -281,6 +286,13 @@ public class EndpointConfiguration { } } } + if (!runningEE) { + disableGroup("enterprise"); + } + + if (!applicationProperties.getSystem().getEnableUrlToPDF()) { + disableEndpoint("url-to-pdf"); + } } public Set getEndpointsForGroup(String group) { diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInspector.java b/src/main/java/stirling/software/SPDF/config/EndpointInspector.java index 47460678..6019ca66 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInspector.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInspector.java @@ -36,7 +36,6 @@ public class EndpointInspector implements ApplicationListener mergePdfs(@ModelAttribute MergePdfsRequest form) throws IOException { List filesToDelete = new ArrayList<>(); // List of temporary files to delete - ByteArrayOutputStream docOutputstream = - new ByteArrayOutputStream(); // Stream for the merged document + File mergedTempFile = null; PDDocument mergedDocument = null; boolean removeCertSign = form.isRemoveCertSign(); @@ -139,21 +138,24 @@ public class MergeController { form.getSortType())); // Sort files based on the given sort type PDFMergerUtility mergerUtility = new PDFMergerUtility(); + long totalSize = 0; for (MultipartFile multipartFile : files) { + totalSize += multipartFile.getSize(); File tempFile = GeneralUtils.convertMultipartFileToFile( multipartFile); // Convert MultipartFile to File filesToDelete.add(tempFile); // Add temp file to the list for later deletion mergerUtility.addSource(tempFile); // Add source file to the merger utility } - mergerUtility.setDestinationStream( - docOutputstream); // Set the output stream for the merged document - mergerUtility.mergeDocuments(null); // Merge the documents - byte[] mergedPdfBytes = docOutputstream.toByteArray(); // Get merged document bytes + mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile(); + mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath()); + + mergerUtility.mergeDocuments( + pdfDocumentFactory.getStreamCacheFunction(totalSize)); // Merge the documents // Load the merged PDF document - mergedDocument = pdfDocumentFactory.load(mergedPdfBytes); + mergedDocument = pdfDocumentFactory.load(mergedTempFile); // Remove signatures if removeCertSign is true if (removeCertSign) { @@ -180,21 +182,23 @@ public class MergeController { String mergedFileName = files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_merged_unsigned.pdf"; - return WebResponseUtils.bytesToWebResponse( - baos.toByteArray(), mergedFileName); // Return the modified PDF + return WebResponseUtils.boasToWebResponse( + baos, mergedFileName); // Return the modified PDF } catch (Exception ex) { log.error("Error in merge pdf process", ex); throw ex; } finally { + if (mergedDocument != null) { + mergedDocument.close(); // Close the merged document + } for (File file : filesToDelete) { if (file != null) { Files.deleteIfExists(file.toPath()); // Delete temporary files } } - docOutputstream.close(); - if (mergedDocument != null) { - mergedDocument.close(); // Close the merged document + if (mergedTempFile != null) { + Files.deleteIfExists(mergedTempFile.toPath()); } } } diff --git a/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java b/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java index 4ec2bd36..fb5375ef 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java @@ -40,9 +40,6 @@ public class SplitPdfBySizeController { @Autowired public SplitPdfBySizeController(CustomPDFDocumentFactory pdfDocumentFactory) { this.pdfDocumentFactory = pdfDocumentFactory; - log.info( - "SplitPdfBySizeController initialized with pdfDocumentFactory: {}", - pdfDocumentFactory.getClass().getSimpleName()); } @PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data") @@ -57,53 +54,49 @@ public class SplitPdfBySizeController { public ResponseEntity autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception { - log.info("Starting PDF split process with request: {}", request); + log.debug("Starting PDF split process with request: {}", request); MultipartFile file = request.getFileInput(); - log.info( - "File received: name={}, size={} bytes", - file.getOriginalFilename(), - file.getSize()); Path zipFile = Files.createTempFile("split_documents", ".zip"); - log.info("Created temporary zip file: {}", zipFile); + log.debug("Created temporary zip file: {}", zipFile); String filename = Filenames.toSimpleFileName(file.getOriginalFilename()) .replaceFirst("[.][^.]+$", ""); - log.info("Base filename for output: {}", filename); + log.debug("Base filename for output: {}", filename); byte[] data = null; try { - log.info("Reading input file bytes"); + log.debug("Reading input file bytes"); byte[] pdfBytes = file.getBytes(); - log.info("Successfully read {} bytes from input file", pdfBytes.length); + log.debug("Successfully read {} bytes from input file", pdfBytes.length); - log.info("Creating ZIP output stream"); + log.debug("Creating ZIP output stream"); try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) { - log.info("Loading PDF document"); + log.debug("Loading PDF document"); try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) { - log.info( + log.debug( "Successfully loaded PDF with {} pages", sourceDocument.getNumberOfPages()); int type = request.getSplitType(); String value = request.getSplitValue(); - log.info("Split type: {}, Split value: {}", type, value); + log.debug("Split type: {}, Split value: {}", type, value); if (type == 0) { - log.info("Processing split by size"); + log.debug("Processing split by size"); long maxBytes = GeneralUtils.convertSizeToBytes(value); - log.info("Max bytes per document: {}", maxBytes); + log.debug("Max bytes per document: {}", maxBytes); handleSplitBySize(sourceDocument, maxBytes, zipOut, filename); } else if (type == 1) { - log.info("Processing split by page count"); + log.debug("Processing split by page count"); int pageCount = Integer.parseInt(value); - log.info("Pages per document: {}", pageCount); + log.debug("Pages per document: {}", pageCount); handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename); } else if (type == 2) { - log.info("Processing split by document count"); + log.debug("Processing split by document count"); int documentCount = Integer.parseInt(value); - log.info("Total number of documents: {}", documentCount); + log.debug("Total number of documents: {}", documentCount); handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename); } else { log.error("Invalid split type: {}", type); @@ -111,7 +104,7 @@ public class SplitPdfBySizeController { "Invalid argument for split type: " + type); } - log.info("PDF splitting completed successfully"); + log.debug("PDF splitting completed successfully"); } catch (Exception e) { log.error("Error loading or processing PDF document", e); throw e; @@ -126,23 +119,23 @@ public class SplitPdfBySizeController { throw e; // Re-throw to ensure proper error response } finally { try { - log.info("Reading ZIP file data"); + log.debug("Reading ZIP file data"); data = Files.readAllBytes(zipFile); - log.info("Successfully read {} bytes from ZIP file", data.length); + log.debug("Successfully read {} bytes from ZIP file", data.length); } catch (IOException e) { log.error("Error reading ZIP file data", e); } try { - log.info("Deleting temporary ZIP file"); + log.debug("Deleting temporary ZIP file"); boolean deleted = Files.deleteIfExists(zipFile); - log.info("Temporary ZIP file deleted: {}", deleted); + log.debug("Temporary ZIP file deleted: {}", deleted); } catch (IOException e) { log.error("Error deleting temporary ZIP file", e); } } - log.info("Returning response with {} bytes of data", data != null ? data.length : 0); + log.debug("Returning response with {} bytes of data", data != null ? data.length : 0); return WebResponseUtils.bytesToWebResponse( data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM); } @@ -150,7 +143,7 @@ public class SplitPdfBySizeController { private void handleSplitBySize( PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename) throws IOException { - log.info("Starting handleSplitBySize with maxBytes={}", maxBytes); + log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes); PDDocument currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); @@ -163,7 +156,7 @@ public class SplitPdfBySizeController { for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { PDPage page = sourceDocument.getPage(pageIndex); - log.info("Processing page {} of {}", pageIndex + 1, totalPages); + log.debug("Processing page {} of {}", pageIndex + 1, totalPages); // Add the page to current document PDPage newPage = new PDPage(page.getCOSObject()); @@ -177,21 +170,21 @@ public class SplitPdfBySizeController { || (pageAdded >= 20); // Always check after 20 pages if (shouldCheckSize) { - log.info("Performing size check after {} pages", pageAdded); + log.debug("Performing size check after {} pages", pageAdded); ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream(); currentDoc.save(checkSizeStream); long actualSize = checkSizeStream.size(); - log.info("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes); + log.debug("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes); if (actualSize > maxBytes) { // We exceeded the limit - remove the last page and save if (currentDoc.getNumberOfPages() > 1) { currentDoc.removePage(currentDoc.getNumberOfPages() - 1); pageIndex--; // Process this page again in the next document - log.info("Size limit exceeded - removed last page"); + log.debug("Size limit exceeded - removed last page"); } - log.info( + log.debug( "Saving document with {} pages as part {}", currentDoc.getNumberOfPages(), fileIndex); @@ -206,7 +199,7 @@ public class SplitPdfBySizeController { int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1); if (pagesToLookAhead > 0) { - log.info( + log.debug( "Testing {} upcoming pages for potential addition", pagesToLookAhead); @@ -231,12 +224,12 @@ public class SplitPdfBySizeController { if (testSize <= maxBytes) { extraPagesAdded++; - log.info( + log.debug( "Test: Can add page {} (size would be {})", testPageIndex + 1, testSize); } else { - log.info( + log.debug( "Test: Cannot add page {} (size would be {})", testPageIndex + 1, testSize); @@ -248,7 +241,7 @@ public class SplitPdfBySizeController { // Add the pages we verified would fit if (extraPagesAdded > 0) { - log.info("Adding {} verified pages ahead", extraPagesAdded); + log.debug("Adding {} verified pages ahead", extraPagesAdded); for (int i = 0; i < extraPagesAdded; i++) { int extraPageIndex = pageIndex + 1 + i; PDPage extraPage = sourceDocument.getPage(extraPageIndex); @@ -265,26 +258,26 @@ public class SplitPdfBySizeController { // Save final document if it has any pages if (currentDoc.getNumberOfPages() > 0) { - log.info( + log.debug( "Saving final document with {} pages as part {}", currentDoc.getNumberOfPages(), fileIndex); saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); } - log.info("Completed handleSplitBySize with {} document parts created", fileIndex - 1); + log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1); } private void handleSplitByPageCount( PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename) throws IOException { - log.info("Starting handleSplitByPageCount with pageCount={}", pageCount); + log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount); int currentPageCount = 0; - log.info("Creating initial output document"); + log.debug("Creating initial output document"); PDDocument currentDoc = null; try { currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); - log.info("Successfully created initial output document"); + log.debug("Successfully created initial output document"); } catch (Exception e) { log.error("Error creating initial output document", e); throw new IOException("Failed to create initial output document", e); @@ -293,49 +286,49 @@ public class SplitPdfBySizeController { int fileIndex = 1; int pageIndex = 0; int totalPages = sourceDocument.getNumberOfPages(); - log.info("Processing {} pages", totalPages); + log.debug("Processing {} pages", totalPages); try { for (PDPage page : sourceDocument.getPages()) { pageIndex++; - log.info("Processing page {} of {}", pageIndex, totalPages); + log.debug("Processing page {} of {}", pageIndex, totalPages); try { - log.info("Adding page {} to current document", pageIndex); + log.debug("Adding page {} to current document", pageIndex); currentDoc.addPage(page); - log.info("Successfully added page {} to current document", pageIndex); + log.debug("Successfully added page {} to current document", pageIndex); } catch (Exception e) { log.error("Error adding page {} to current document", pageIndex, e); throw new IOException("Failed to add page to document", e); } currentPageCount++; - log.info("Current page count: {}/{}", currentPageCount, pageCount); + log.debug("Current page count: {}/{}", currentPageCount, pageCount); if (currentPageCount == pageCount) { - log.info( + log.debug( "Reached target page count ({}), saving current document as part {}", pageCount, fileIndex); try { saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); - log.info("Successfully saved document part {}", fileIndex - 1); + log.debug("Successfully saved document part {}", fileIndex - 1); } catch (Exception e) { log.error("Error saving document part {}", fileIndex - 1, e); throw e; } try { - log.info("Creating new document for next part"); + log.debug("Creating new document for next part"); currentDoc = new PDDocument(); - log.info("Successfully created new document"); + log.debug("Successfully created new document"); } catch (Exception e) { log.error("Error creating new document for next part", e); throw new IOException("Failed to create new document", e); } currentPageCount = 0; - log.info("Reset current page count to 0"); + log.debug("Reset current page count to 0"); } } } catch (Exception e) { @@ -346,34 +339,34 @@ public class SplitPdfBySizeController { // Add the last document if it contains any pages try { if (currentDoc.getPages().getCount() != 0) { - log.info( + log.debug( "Saving final document with {} pages as part {}", currentDoc.getPages().getCount(), fileIndex); try { saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); - log.info("Successfully saved final document part {}", fileIndex - 1); + log.debug("Successfully saved final document part {}", fileIndex - 1); } catch (Exception e) { log.error("Error saving final document part {}", fileIndex - 1, e); throw e; } } else { - log.info("Final document has no pages, skipping"); + log.debug("Final document has no pages, skipping"); } } catch (Exception e) { log.error("Error checking or saving final document", e); throw new IOException("Failed to process final document", e); } finally { try { - log.info("Closing final document"); + log.debug("Closing final document"); currentDoc.close(); - log.info("Successfully closed final document"); + log.debug("Successfully closed final document"); } catch (Exception e) { log.error("Error closing final document", e); } } - log.info("Completed handleSplitByPageCount with {} document parts created", fileIndex - 1); + log.debug("Completed handleSplitByPageCount with {} document parts created", fileIndex - 1); } private void handleSplitByDocCount( @@ -382,40 +375,40 @@ public class SplitPdfBySizeController { ZipOutputStream zipOut, String baseFilename) throws IOException { - log.info("Starting handleSplitByDocCount with documentCount={}", documentCount); + log.debug("Starting handleSplitByDocCount with documentCount={}", documentCount); int totalPageCount = sourceDocument.getNumberOfPages(); - log.info("Total pages in source document: {}", totalPageCount); + log.debug("Total pages in source document: {}", totalPageCount); int pagesPerDocument = totalPageCount / documentCount; int extraPages = totalPageCount % documentCount; - log.info("Pages per document: {}, Extra pages: {}", pagesPerDocument, extraPages); + log.debug("Pages per document: {}, Extra pages: {}", pagesPerDocument, extraPages); int currentPageIndex = 0; int fileIndex = 1; for (int i = 0; i < documentCount; i++) { - log.info("Creating document {} of {}", i + 1, documentCount); + log.debug("Creating document {} of {}", i + 1, documentCount); PDDocument currentDoc = null; try { currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); - log.info("Successfully created document {} of {}", i + 1, documentCount); + log.debug("Successfully created document {} of {}", i + 1, documentCount); } catch (Exception e) { log.error("Error creating document {} of {}", i + 1, documentCount, e); throw new IOException("Failed to create document", e); } int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); - log.info("Adding {} pages to document {}", pagesToAdd, i + 1); + log.debug("Adding {} pages to document {}", pagesToAdd, i + 1); for (int j = 0; j < pagesToAdd; j++) { try { - log.info( + log.debug( "Adding page {} (index {}) to document {}", j + 1, currentPageIndex, i + 1); currentDoc.addPage(sourceDocument.getPage(currentPageIndex)); - log.info("Successfully added page {} to document {}", j + 1, i + 1); + log.debug("Successfully added page {} to document {}", j + 1, i + 1); currentPageIndex++; } catch (Exception e) { log.error("Error adding page {} to document {}", j + 1, i + 1, e); @@ -424,37 +417,37 @@ public class SplitPdfBySizeController { } try { - log.info("Saving document {} with {} pages", i + 1, pagesToAdd); + log.debug("Saving document {} with {} pages", i + 1, pagesToAdd); saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++); - log.info("Successfully saved document {}", i + 1); + log.debug("Successfully saved document {}", i + 1); } catch (Exception e) { log.error("Error saving document {}", i + 1, e); throw e; } } - log.info("Completed handleSplitByDocCount with {} documents created", documentCount); + log.debug("Completed handleSplitByDocCount with {} documents created", documentCount); } private void saveDocumentToZip( PDDocument document, ZipOutputStream zipOut, String baseFilename, int index) throws IOException { - log.info("Starting saveDocumentToZip for document part {}", index); + log.debug("Starting saveDocumentToZip for document part {}", index); ByteArrayOutputStream outStream = new ByteArrayOutputStream(); try { - log.info("Saving document part {} to byte array", index); + log.debug("Saving document part {} to byte array", index); document.save(outStream); - log.info("Successfully saved document part {} ({} bytes)", index, outStream.size()); + log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size()); } catch (Exception e) { log.error("Error saving document part {} to byte array", index, e); throw new IOException("Failed to save document to byte array", e); } try { - log.info("Closing document part {}", index); + log.debug("Closing document part {}", index); document.close(); - log.info("Successfully closed document part {}", index); + log.debug("Successfully closed document part {}", index); } catch (Exception e) { log.error("Error closing document part {}", index, e); // Continue despite close error @@ -463,17 +456,17 @@ public class SplitPdfBySizeController { try { // Create a new zip entry String entryName = baseFilename + "_" + index + ".pdf"; - log.info("Creating ZIP entry: {}", entryName); + log.debug("Creating ZIP entry: {}", entryName); ZipEntry zipEntry = new ZipEntry(entryName); zipOut.putNextEntry(zipEntry); byte[] bytes = outStream.toByteArray(); - log.info("Writing {} bytes to ZIP entry", bytes.length); + log.debug("Writing {} bytes to ZIP entry", bytes.length); zipOut.write(bytes); - log.info("Closing ZIP entry"); + log.debug("Closing ZIP entry"); zipOut.closeEntry(); - log.info("Successfully added document part {} to ZIP", index); + log.debug("Successfully added document part {} to ZIP", index); } catch (Exception e) { log.error("Error adding document part {} to ZIP", index, e); throw new IOException("Failed to add document to ZIP file", e); diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index c93f652e..34c72653 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -30,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.security.UserService; import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; +import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.AuthenticationType; import stirling.software.SPDF.model.Role; import stirling.software.SPDF.model.User; @@ -45,10 +46,15 @@ public class UserController { private static final String LOGIN_MESSAGETYPE_CREDSUPDATED = "/login?messageType=credsUpdated"; private final UserService userService; private final SessionPersistentRegistry sessionRegistry; + private final ApplicationProperties applicationProperties; - public UserController(UserService userService, SessionPersistentRegistry sessionRegistry) { + public UserController( + UserService userService, + SessionPersistentRegistry sessionRegistry, + ApplicationProperties applicationProperties) { this.userService = userService; this.sessionRegistry = sessionRegistry; + this.applicationProperties = applicationProperties; } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @@ -192,39 +198,44 @@ public class UserController { boolean forceChange) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(username)) { - return new RedirectView("/addUsers?messageType=invalidUsername", true); + return new RedirectView("/adminSettings?messageType=invalidUsername", true); + } + if (applicationProperties.getPremium().isEnabled() + && applicationProperties.getPremium().getMaxUsers() + <= userService.getTotalUsersCount()) { + return new RedirectView("/adminSettings?messageType=maxUsersReached", true); } Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { User user = userOpt.get(); if (user.getUsername().equalsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=usernameExists", true); + return new RedirectView("/adminSettings?messageType=usernameExists", true); } } if (userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=usernameExists", true); + return new RedirectView("/adminSettings?messageType=usernameExists", true); } 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", true); + return new RedirectView("/adminSettings?messageType=invalidRole", true); } } catch (IllegalArgumentException e) { // If the role ID is not valid, redirect with an error message - return new RedirectView("/addUsers?messageType=invalidRole", true); + return new RedirectView("/adminSettings?messageType=invalidRole", true); } if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { userService.saveUser(username, AuthenticationType.SSO, role); } else { if (password.isBlank()) { - return new RedirectView("/addUsers?messageType=invalidPassword", true); + return new RedirectView("/adminSettings?messageType=invalidPassword", true); } userService.saveUser(username, password, role, forceChange); } return new RedirectView( - "/addUsers", // Redirect to account page after adding the user + "/adminSettings", // Redirect to account page after adding the user true); } @@ -237,32 +248,32 @@ public class UserController { throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (!userOpt.isPresent()) { - return new RedirectView("/addUsers?messageType=userNotFound", true); + return new RedirectView("/adminSettings?messageType=userNotFound", true); } if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=userNotFound", true); + return new RedirectView("/adminSettings?messageType=userNotFound", true); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=downgradeCurrentUser", true); + return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true); } 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", true); + return new RedirectView("/adminSettings?messageType=invalidRole", true); } } catch (IllegalArgumentException e) { // If the role ID is not valid, redirect with an error message - return new RedirectView("/addUsers?messageType=invalidRole", true); + return new RedirectView("/adminSettings?messageType=invalidRole", true); } User user = userOpt.get(); userService.changeRole(user, role); return new RedirectView( - "/addUsers", // Redirect to account page after adding the user + "/adminSettings", // Redirect to account page after adding the user true); } @@ -275,16 +286,16 @@ public class UserController { throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isEmpty()) { - return new RedirectView("/addUsers?messageType=userNotFound", true); + return new RedirectView("/adminSettings?messageType=userNotFound", true); } if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=userNotFound", true); + return new RedirectView("/adminSettings?messageType=userNotFound", true); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=disabledCurrentUser", true); + return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true); } User user = userOpt.get(); userService.changeUserEnabled(user, enabled); @@ -304,7 +315,7 @@ public class UserController { } } return new RedirectView( - "/addUsers", // Redirect to account page after adding the user + "/adminSettings", // Redirect to account page after adding the user true); } @@ -313,13 +324,13 @@ public class UserController { public RedirectView deleteUser( @PathVariable("username") String username, Authentication authentication) { if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=deleteUsernameExists", true); + return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/addUsers?messageType=deleteCurrentUser", true); + return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true); } // Invalidate all sessions before deleting the user List sessionsInformations = @@ -329,7 +340,7 @@ public class UserController { sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId()); } userService.deleteUser(username); - return new RedirectView("/addUsers", true); + return new RedirectView("/adminSettings", true); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") diff --git a/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java b/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java index b762c088..941d5579 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java +++ b/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java @@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.RuntimePathConfig; +import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.api.converters.UrlToPdfRequest; import stirling.software.SPDF.service.CustomPDFDocumentFactory; import stirling.software.SPDF.utils.GeneralUtils; @@ -35,12 +36,16 @@ public class ConvertWebsiteToPDF { private final CustomPDFDocumentFactory pdfDocumentFactory; private final RuntimePathConfig runtimePathConfig; + private final ApplicationProperties applicationProperties; @Autowired public ConvertWebsiteToPDF( - CustomPDFDocumentFactory pdfDocumentFactory, RuntimePathConfig runtimePathConfig) { + CustomPDFDocumentFactory pdfDocumentFactory, + RuntimePathConfig runtimePathConfig, + ApplicationProperties applicationProperties) { this.pdfDocumentFactory = pdfDocumentFactory; this.runtimePathConfig = runtimePathConfig; + this.applicationProperties = applicationProperties; } @PostMapping(consumes = "multipart/form-data", value = "/url/pdf") @@ -53,6 +58,9 @@ public class ConvertWebsiteToPDF { throws IOException, InterruptedException { String URL = request.getUrlInput(); + if (!applicationProperties.getSystem().getEnableUrlToPDF()) { + throw new IllegalArgumentException("This endpoint has been disabled by the admin."); + } // Validate the URL format if (!URL.matches("^https?://.*") || !GeneralUtils.isValidURL(URL)) { throw new IllegalArgumentException("Invalid URL format provided."); diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java index 4bca4988..66ac528e 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java @@ -86,7 +86,7 @@ public class MetadataController { allRequestParams = new java.util.HashMap(); } // Load the PDF file into a PDDocument - PDDocument document = pdfDocumentFactory.load(pdfFile); + PDDocument document = pdfDocumentFactory.load(pdfFile, true); // Get the document information from the PDF PDDocumentInformation info = document.getDocumentInformation(); diff --git a/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java b/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java index e1959fc8..28499e1e 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java @@ -51,11 +51,12 @@ public class SanitizeController { MultipartFile inputFile = request.getFileInput(); boolean removeJavaScript = request.isRemoveJavaScript(); boolean removeEmbeddedFiles = request.isRemoveEmbeddedFiles(); + boolean removeXMPMetadata = request.isRemoveXMPMetadata(); boolean removeMetadata = request.isRemoveMetadata(); boolean removeLinks = request.isRemoveLinks(); boolean removeFonts = request.isRemoveFonts(); - PDDocument document = pdfDocumentFactory.load(inputFile); + PDDocument document = pdfDocumentFactory.load(inputFile, true); if (removeJavaScript) { sanitizeJavaScript(document); } @@ -64,10 +65,14 @@ public class SanitizeController { sanitizeEmbeddedFiles(document); } - if (removeMetadata) { - sanitizeMetadata(document); + if (removeXMPMetadata) { + sanitizeXMPMetadata(document); } + if (removeMetadata) { + sanitizeDocumentInfoMetadata(document); + } + if (removeLinks) { sanitizeLinks(document); } @@ -145,7 +150,7 @@ public class SanitizeController { } } - private void sanitizeMetadata(PDDocument document) { + private void sanitizeXMPMetadata(PDDocument document) { if (document.getDocumentCatalog() != null) { PDMetadata metadata = document.getDocumentCatalog().getMetadata(); if (metadata != null) { @@ -153,6 +158,16 @@ public class SanitizeController { } } } + + private void sanitizeDocumentInfoMetadata(PDDocument document) { + PDDocumentInformation docInfo = document.getDocumentInformation(); + if (docInfo != null) { + PDDocumentInformation newInfo = new PDDocumentInformation(); + document.setDocumentInformation(newInfo); + } + } + + private void sanitizeLinks(PDDocument document) throws IOException { for (PDPage page : document.getPages()) { diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 6a5b7344..f9583a7e 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -123,11 +123,11 @@ public class AccountWebController { if (securityProps.isSaml2Active() && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getEnterpriseEdition().isEnabled()) { + && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); - if (applicationProperties.getEnterpriseEdition().isSsoAutoLogin()) { + if (applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { return "redirect:" + request.getRequestURL() + saml2AuthenticationPath; } else { providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); @@ -201,7 +201,13 @@ public class AccountWebController { } @PreAuthorize("hasRole('ROLE_ADMIN')") - @GetMapping("/addUsers") + @GetMapping("/usage") + public String showUsage() { + return "usage"; + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/adminSettings") public String showAddUserForm( HttpServletRequest request, Model model, Authentication authentication) { List allUsers = userRepository.findAll(); @@ -337,7 +343,8 @@ public class AccountWebController { model.addAttribute("maxSessions", maxSessions); model.addAttribute("maxUserSessions", maxUserSessions); model.addAttribute("sessionCount", sessionCount); - return "addUsers"; + model.addAttribute("maxEnterpriseUsers", applicationProperties.getPremium().getMaxUsers()); + return "adminSettings"; } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") diff --git a/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 13c5e7ff..aae09341 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -22,6 +22,7 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.EndpointInspector; import stirling.software.SPDF.config.StartupApplicationListener; import stirling.software.SPDF.model.ApplicationProperties; @@ -32,15 +33,17 @@ import stirling.software.SPDF.model.ApplicationProperties; public class MetricsController { private final ApplicationProperties applicationProperties; - private final MeterRegistry meterRegistry; - + private final EndpointInspector endpointInspector; private boolean metricsEnabled; public MetricsController( - ApplicationProperties applicationProperties, MeterRegistry meterRegistry) { + ApplicationProperties applicationProperties, + MeterRegistry meterRegistry, + EndpointInspector endpointInspector) { this.applicationProperties = applicationProperties; this.meterRegistry = meterRegistry; + this.endpointInspector = endpointInspector; } @PostConstruct @@ -208,25 +211,43 @@ public class MetricsController { } private double getRequestCount(String method, Optional endpoint) { - log.info( - "Getting request count for method: {}, endpoint: {}", - method, - endpoint.orElse("all")); - double count = - meterRegistry.find("http.requests").tag("method", method).counters().stream() - .filter( - counter -> - !endpoint.isPresent() - || endpoint.get() - .equals(counter.getId().getTag("uri"))) - .mapToDouble(Counter::count) - .sum(); - log.info("Request count: {}", count); - return count; + return meterRegistry.find("http.requests").tag("method", method).counters().stream() + .filter( + counter -> { + String uri = counter.getId().getTag("uri"); + + // Apply filtering logic - Skip if uri is null + if (uri == null) { + return false; + } + + // For POST requests, only include if they start with /api/v1 + if ("POST".equals(method) && !uri.contains("api/v1")) { + return false; + } + + if (uri.contains(".txt")) { + return false; + } + + // For GET requests, validate if we have a list of valid endpoints + final boolean validateGetEndpoints = + endpointInspector.getValidGetEndpoints().size() != 0; + if ("GET".equals(method) + && validateGetEndpoints + && !endpointInspector.isValidGetEndpoint(uri)) { + log.debug("Skipping invalid GET endpoint: {}", uri); + return false; + } + + // Filter for specific endpoint if provided + return !endpoint.isPresent() || endpoint.get().equals(uri); + }) + .mapToDouble(Counter::count) + .sum(); } private List getEndpointCounts(String method) { - log.info("Getting endpoint counts for method: {}", method); Map counts = new HashMap<>(); meterRegistry .find("http.requests") @@ -235,28 +256,72 @@ public class MetricsController { .forEach( counter -> { String uri = counter.getId().getTag("uri"); + + // Skip if uri is null + if (uri == null) { + return; + } + + // For POST requests, only include if they start with /api/v1 + if ("POST".equals(method) && !uri.contains("api/v1")) { + return; + } + + if (uri.contains(".txt")) { + return; + } + + // For GET requests, validate if we have a list of valid endpoints + final boolean validateGetEndpoints = + endpointInspector.getValidGetEndpoints().size() != 0; + if ("GET".equals(method) + && validateGetEndpoints + && !endpointInspector.isValidGetEndpoint(uri)) { + log.debug("Skipping invalid GET endpoint: {}", uri); + return; + } + counts.merge(uri, counter.count(), Double::sum); }); - List result = - counts.entrySet().stream() - .map(entry -> new EndpointCount(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparing(EndpointCount::getCount).reversed()) - .collect(Collectors.toList()); - log.info("Found {} endpoints with counts", result.size()); - return result; + + return counts.entrySet().stream() + .map(entry -> new EndpointCount(entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(EndpointCount::getCount).reversed()) + .collect(Collectors.toList()); } private double getUniqueUserCount(String method, Optional endpoint) { - log.info( - "Getting unique user count for method: {}, endpoint: {}", - method, - endpoint.orElse("all")); Set uniqueUsers = new HashSet<>(); meterRegistry.find("http.requests").tag("method", method).counters().stream() .filter( - counter -> - !endpoint.isPresent() - || endpoint.get().equals(counter.getId().getTag("uri"))) + counter -> { + String uri = counter.getId().getTag("uri"); + + // Skip if uri is null + if (uri == null) { + return false; + } + + // For POST requests, only include if they start with /api/v1 + if ("POST".equals(method) && !uri.contains("api/v1")) { + return false; + } + + if (uri.contains(".txt")) { + return false; + } + + // For GET requests, validate if we have a list of valid endpoints + final boolean validateGetEndpoints = + endpointInspector.getValidGetEndpoints().size() != 0; + if ("GET".equals(method) + && validateGetEndpoints + && !endpointInspector.isValidGetEndpoint(uri)) { + log.debug("Skipping invalid GET endpoint: {}", uri); + return false; + } + return !endpoint.isPresent() || endpoint.get().equals(uri); + }) .forEach( counter -> { String session = counter.getId().getTag("session"); @@ -264,12 +329,10 @@ public class MetricsController { uniqueUsers.add(session); } }); - log.info("Unique user count: {}", uniqueUsers.size()); return uniqueUsers.size(); } private List getUniqueUserCounts(String method) { - log.info("Getting unique user counts for method: {}", method); Map> uniqueUsers = new HashMap<>(); meterRegistry .find("http.requests") @@ -283,13 +346,10 @@ public class MetricsController { uniqueUsers.computeIfAbsent(uri, k -> new HashSet<>()).add(session); } }); - List result = - uniqueUsers.entrySet().stream() - .map(entry -> new EndpointCount(entry.getKey(), entry.getValue().size())) - .sorted(Comparator.comparing(EndpointCount::getCount).reversed()) - .collect(Collectors.toList()); - log.info("Found {} endpoints with unique user counts", result.size()); - return result; + return uniqueUsers.entrySet().stream() + .map(entry -> new EndpointCount(entry.getKey(), entry.getValue().size())) + .sorted(Comparator.comparing(EndpointCount::getCount).reversed()) + .collect(Collectors.toList()); } @GetMapping("/uptime") diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index fdce513a..36f6f82b 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -81,6 +81,8 @@ public class ApplicationProperties { private Endpoints endpoints = new Endpoints(); private Metrics metrics = new Metrics(); private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated(); + + private Premium premium = new Premium(); private EnterpriseEdition enterpriseEdition = new EnterpriseEdition(); private AutoPipeline autoPipeline = new AutoPipeline(); private ProcessExecutor processExecutor = new ProcessExecutor(); @@ -287,6 +289,7 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Datasource datasource; private Boolean disableSanitize; + private Boolean enableUrlToPDF; private CustomPaths customPaths = new CustomPaths(); public boolean isAnalyticsEnabled() { @@ -390,6 +393,7 @@ public class ApplicationProperties { private String appVersion; } + // TODO: Remove post migration @Data public static class EnterpriseEdition { private boolean enabled; @@ -415,6 +419,50 @@ public class ApplicationProperties { } } + @Data + public static class Premium { + private boolean enabled; + @ToString.Exclude private String key; + private int maxUsers; + private ProFeatures proFeatures = new ProFeatures(); + private EnterpriseFeatures enterpriseFeatures = new EnterpriseFeatures(); + + @Data + public static class ProFeatures { + private boolean ssoAutoLogin; + private CustomMetadata customMetadata = new CustomMetadata(); + + @Data + public static class CustomMetadata { + private boolean autoUpdateMetadata; + private String author; + private String creator; + private String producer; + + public String getCreator() { + return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator; + } + + public String getProducer() { + return producer == null || producer.trim().isEmpty() + ? "Stirling-PDF" + : producer; + } + } + } + + @Data + public static class EnterpriseFeatures { + private PersistentMetrics persistentMetrics = new PersistentMetrics(); + + @Data + public static class PersistentMetrics { + private boolean enabled; + private int retentionDays; + } + } + } + @Data public static class ProcessExecutor { private SessionLimit sessionLimit = new SessionLimit(); diff --git a/src/main/java/stirling/software/SPDF/model/api/security/SanitizePdfRequest.java b/src/main/java/stirling/software/SPDF/model/api/security/SanitizePdfRequest.java index f3006736..e930db75 100644 --- a/src/main/java/stirling/software/SPDF/model/api/security/SanitizePdfRequest.java +++ b/src/main/java/stirling/software/SPDF/model/api/security/SanitizePdfRequest.java @@ -17,9 +17,12 @@ public class SanitizePdfRequest extends PDFFile { @Schema(description = "Remove embedded files from the PDF", defaultValue = "false") private boolean removeEmbeddedFiles; - @Schema(description = "Remove metadata from the PDF", defaultValue = "false") - private boolean removeMetadata; + @Schema(description = "Remove XMP metadata from the PDF", defaultValue = "false") + private boolean removeXMPMetadata; + @Schema(description = "Remove document info metadata from the PDF", defaultValue = "false") + private boolean removeMetadata; + @Schema(description = "Remove links from the PDF", defaultValue = "false") private boolean removeLinks; diff --git a/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java b/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java index 92055a76..73868347 100644 --- a/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java +++ b/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java @@ -68,10 +68,18 @@ public class CustomPDFDocumentFactory { } /** - * Main entry point for loading a PDF document from a file. Automatically selects the most - * appropriate loading strategy. - */ + * Main entry point for loading a PDF document from a file. Automatically selects the most + * appropriate loading strategy. + */ public PDDocument load(File file) throws IOException { + return load(file, false); + } + + /** + * Main entry point for loading a PDF document from a file with read-only option. + * Automatically selects the most appropriate loading strategy. + */ + public PDDocument load(File file, boolean readOnly) throws IOException { if (file == null) { throw new IllegalArgumentException("File cannot be null"); } @@ -79,14 +87,26 @@ public class CustomPDFDocumentFactory { long fileSize = file.length(); log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024)); - return loadAdaptively(file, fileSize); + PDDocument doc = loadAdaptively(file, fileSize); + if (!readOnly) { + postProcessDocument(doc); + } + return doc; } /** - * Main entry point for loading a PDF document from a Path. Automatically selects the most - * appropriate loading strategy. - */ + * Main entry point for loading a PDF document from a Path. Automatically selects the most + * appropriate loading strategy. + */ public PDDocument load(Path path) throws IOException { + return load(path, false); + } + + /** + * Main entry point for loading a PDF document from a Path with read-only option. + * Automatically selects the most appropriate loading strategy. + */ + public PDDocument load(Path path, boolean readOnly) throws IOException { if (path == null) { throw new IllegalArgumentException("File cannot be null"); } @@ -94,11 +114,20 @@ public class CustomPDFDocumentFactory { long fileSize = Files.size(path); log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024)); - return loadAdaptively(path.toFile(), fileSize); + PDDocument doc = loadAdaptively(path.toFile(), fileSize); + if (!readOnly) { + postProcessDocument(doc); + } + return doc; } /** Load a PDF from byte array with automatic optimization. */ public PDDocument load(byte[] input) throws IOException { + return load(input, false); + } + + /** Load a PDF from byte array with automatic optimization and read-only option. */ + public PDDocument load(byte[] input, boolean readOnly) throws IOException { if (input == null) { throw new IllegalArgumentException("Input bytes cannot be null"); } @@ -106,11 +135,20 @@ public class CustomPDFDocumentFactory { long dataSize = input.length; log.debug("Loading PDF from byte array, size: {}MB", dataSize / (1024 * 1024)); - return loadAdaptively(input, dataSize); + PDDocument doc = loadAdaptively(input, dataSize); + if (!readOnly) { + postProcessDocument(doc); + } + return doc; } /** Load a PDF from InputStream with automatic optimization. */ public PDDocument load(InputStream input) throws IOException { + return load(input, false); + } + + /** Load a PDF from InputStream with automatic optimization and read-only option. */ + public PDDocument load(InputStream input, boolean readOnly) throws IOException { if (input == null) { throw new IllegalArgumentException("InputStream cannot be null"); } @@ -119,11 +157,20 @@ public class CustomPDFDocumentFactory { Path tempFile = createTempFile("pdf-stream-"); Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); - return loadAdaptively(tempFile.toFile(), Files.size(tempFile)); + PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile)); + if (!readOnly) { + postProcessDocument(doc); + } + return doc; } /** Load with password from InputStream */ public PDDocument load(InputStream input, String password) throws IOException { + return load(input, password, false); + } + + /** Load with password from InputStream and read-only option */ + public PDDocument load(InputStream input, String password, boolean readOnly) throws IOException { if (input == null) { throw new IllegalArgumentException("InputStream cannot be null"); } @@ -132,14 +179,59 @@ public class CustomPDFDocumentFactory { Path tempFile = createTempFile("pdf-stream-"); Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); - return loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password); + PDDocument doc = loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password); + if (!readOnly) { + postProcessDocument(doc); + } + return doc; } + /** Load from a file path string */ + public PDDocument load(String path) throws IOException { + return load(path, false); + } + + /** Load from a file path string with read-only option */ + public PDDocument load(String path, boolean readOnly) throws IOException { + return load(new File(path), readOnly); + } + + /** Load from a PDFFile object */ + public PDDocument load(PDFFile pdfFile) throws IOException { + return load(pdfFile, false); + } + + /** Load from a PDFFile object with read-only option */ + public PDDocument load(PDFFile pdfFile, boolean readOnly) throws IOException { + return load(pdfFile.getFileInput(), readOnly); + } + + /** Load from a MultipartFile */ + public PDDocument load(MultipartFile pdfFile) throws IOException { + return load(pdfFile, false); + } + + /** Load from a MultipartFile with read-only option */ + public PDDocument load(MultipartFile pdfFile, boolean readOnly) throws IOException { + return load(pdfFile.getInputStream(), readOnly); + + } + + /** Load with password from MultipartFile */ + public PDDocument load(MultipartFile fileInput, String password) throws IOException { + return load(fileInput, password, false); + } + + /** Load with password from MultipartFile with read-only option */ + public PDDocument load(MultipartFile fileInput, String password, boolean readOnly) throws IOException { + return load(fileInput.getInputStream(), password, readOnly); + } + /** * Determine the appropriate caching strategy based on file size and available memory. This * common method is used by both password and non-password loading paths. */ - private StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { + public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) { long maxMemory = Runtime.getRuntime().maxMemory(); long freeMemory = Runtime.getRuntime().freeMemory(); long totalMemory = Runtime.getRuntime().totalMemory(); @@ -197,8 +289,6 @@ public class CustomPDFDocumentFactory { } else { throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); } - - postProcessDocument(document); return document; } @@ -220,8 +310,6 @@ public class CustomPDFDocumentFactory { } else { throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); } - - postProcessDocument(document); return document; } @@ -384,23 +472,4 @@ public class CustomPDFDocumentFactory { } } - /** Load from a file path string */ - public PDDocument load(String path) throws IOException { - return load(new File(path)); - } - - /** Load from a PDFFile object */ - public PDDocument load(PDFFile pdfFile) throws IOException { - return load(pdfFile.getFileInput()); - } - - /** Load from a MultipartFile */ - public PDDocument load(MultipartFile pdfFile) throws IOException { - return load(pdfFile.getInputStream()); - } - - /** Load with password from MultipartFile */ - public PDDocument load(MultipartFile fileInput, String password) throws IOException { - return load(fileInput.getInputStream(), password); - } } diff --git a/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java b/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java index f6e8a5b5..9586ad34 100644 --- a/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java +++ b/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java @@ -64,10 +64,19 @@ public class PdfMetadataService { String creator = stirlingPDFLabel; - if (applicationProperties.getEnterpriseEdition().getCustomMetadata().isAutoUpdateMetadata() + if (applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .isAutoUpdateMetadata() && runningEE) { - creator = applicationProperties.getEnterpriseEdition().getCustomMetadata().getCreator(); + creator = + applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .getCreator(); pdf.getDocumentInformation().setProducer(stirlingPDFLabel); } @@ -84,9 +93,18 @@ public class PdfMetadataService { pdf.getDocumentInformation().setModificationDate(Calendar.getInstance()); String author = pdfMetadata.getAuthor(); - if (applicationProperties.getEnterpriseEdition().getCustomMetadata().isAutoUpdateMetadata() + if (applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .isAutoUpdateMetadata() && runningEE) { - author = applicationProperties.getEnterpriseEdition().getCustomMetadata().getAuthor(); + author = + applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .getAuthor(); if (userService != null) { author = author.replace("username", userService.getCurrentUsername()); diff --git a/src/main/java/stirling/software/SPDF/service/PostHogService.java b/src/main/java/stirling/software/SPDF/service/PostHogService.java index 8e7cd6ab..29d0a6f6 100644 --- a/src/main/java/stirling/software/SPDF/service/PostHogService.java +++ b/src/main/java/stirling/software/SPDF/service/PostHogService.java @@ -334,27 +334,40 @@ public class PostHogService { addIfNotEmpty( properties, "enterpriseEdition_enabled", - applicationProperties.getEnterpriseEdition().isEnabled()); - if (applicationProperties.getEnterpriseEdition().isEnabled()) { + applicationProperties.getPremium().isEnabled()); + if (applicationProperties.getPremium().isEnabled()) { addIfNotEmpty( properties, "enterpriseEdition_customMetadata_autoUpdateMetadata", applicationProperties - .getEnterpriseEdition() + .getPremium() + .getProFeatures() .getCustomMetadata() .isAutoUpdateMetadata()); addIfNotEmpty( properties, "enterpriseEdition_customMetadata_author", - applicationProperties.getEnterpriseEdition().getCustomMetadata().getAuthor()); + applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .getAuthor()); addIfNotEmpty( properties, "enterpriseEdition_customMetadata_creator", - applicationProperties.getEnterpriseEdition().getCustomMetadata().getCreator()); + applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .getCreator()); addIfNotEmpty( properties, "enterpriseEdition_customMetadata_producer", - applicationProperties.getEnterpriseEdition().getCustomMetadata().getProducer()); + applicationProperties + .getPremium() + .getProFeatures() + .getCustomMetadata() + .getProducer()); } // Capture AutoPipeline properties addIfNotEmpty( diff --git a/src/main/resources/messages_ar_AR.properties b/src/main/resources/messages_ar_AR.properties index 037f398b..e1851ce4 100644 --- a/src/main/resources/messages_ar_AR.properties +++ b/src/main/resources/messages_ar_AR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=المستخدمين النشطين: adminUserSettings.disabledUsers=المستخدمين المعطلين: adminUserSettings.totalUsers=إجمالي المستخدمين: adminUserSettings.lastRequest=آخر طلب +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=استيراد/تصدير قاعدة البيانات database.header=استيراد/تصدير قاعدة البيانات @@ -709,9 +733,10 @@ sanitizePDF.title=تنظيف PDF sanitizePDF.header=تنظيف ملف PDF sanitizePDF.selectText.1=إزالة إجراءات جافا سكريبت sanitizePDF.selectText.2=إزالة الملفات المضمنة -sanitizePDF.selectText.3=إزالة البيانات الوصفية +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=إزالة الروابط sanitizePDF.selectText.5=إزالة الخطوط +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=تنظيف PDF diff --git a/src/main/resources/messages_az_AZ.properties b/src/main/resources/messages_az_AZ.properties index e46bdb51..e09b49a6 100644 --- a/src/main/resources/messages_az_AZ.properties +++ b/src/main/resources/messages_az_AZ.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktiv İstifadəçilər: adminUserSettings.disabledUsers=Deaktiv İstifadəçilər: adminUserSettings.totalUsers=Ümumi İstifadəçilər: adminUserSettings.lastRequest=Son sorğu +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Verilənlər bazasını Daxil/Xaric Et database.header=Verilənlər bazasını Daxil/Xaric Et @@ -709,9 +733,10 @@ sanitizePDF.title=PDF-i Təmizlə sanitizePDF.header=PDF Faylını Təmizlə sanitizePDF.selectText.1=JavaScript Fəaliyyətlərini Sil sanitizePDF.selectText.2=Daxil Edilmiş Faylları Sil -sanitizePDF.selectText.3=Metadatanı Sil +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Linkləri Sil sanitizePDF.selectText.5=Şriftləri Sil +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF-i Təmizlə diff --git a/src/main/resources/messages_bg_BG.properties b/src/main/resources/messages_bg_BG.properties index df91050b..359a1e65 100644 --- a/src/main/resources/messages_bg_BG.properties +++ b/src/main/resources/messages_bg_BG.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Активни потребители: adminUserSettings.disabledUsers=Деактивирани потребители: adminUserSettings.totalUsers=Общо потребители: adminUserSettings.lastRequest=Последна заявка +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Импорт/Експорт на база данни database.header=Импорт/Експорт на база данни @@ -709,9 +733,10 @@ sanitizePDF.title=Дезинфектирай PDF sanitizePDF.header=Дезинфектира PDF файл sanitizePDF.selectText.1=Премахва JavaScript действия sanitizePDF.selectText.2=Премахва вградени файлове -sanitizePDF.selectText.3=Премахва метаданни +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Премахва линкове sanitizePDF.selectText.5=Премахва шрифтове +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Дезинфектирай PDF diff --git a/src/main/resources/messages_ca_CA.properties b/src/main/resources/messages_ca_CA.properties index 7f0b266e..27ad793f 100644 --- a/src/main/resources/messages_ca_CA.properties +++ b/src/main/resources/messages_ca_CA.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Usuaris Actius: adminUserSettings.disabledUsers=Usuaris Deshabilitats: adminUserSettings.totalUsers=Total d'Usuaris: adminUserSettings.lastRequest=Darrera Sol·licitud +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Importació/Exportació de Base de Dades database.header=Importació/Exportació de Base de Dades @@ -709,9 +733,10 @@ sanitizePDF.title=Neteja PDF sanitizePDF.header=Neteja un fitxer PDF sanitizePDF.selectText.1=Elimina accions JavaScript sanitizePDF.selectText.2=Elimina fitxers incrustats -sanitizePDF.selectText.3=Elimina metadades +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Elimina enllaços sanitizePDF.selectText.5=Elimina fonts +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Neteja PDF diff --git a/src/main/resources/messages_cs_CZ.properties b/src/main/resources/messages_cs_CZ.properties index 1a3970f5..011200fe 100644 --- a/src/main/resources/messages_cs_CZ.properties +++ b/src/main/resources/messages_cs_CZ.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktivní uživatelé: adminUserSettings.disabledUsers=Deaktivovaní uživatelé: adminUserSettings.totalUsers=Celkem uživatelů: adminUserSettings.lastRequest=Poslední požadavek +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Import/Export databáze database.header=Import/Export databáze @@ -709,9 +733,10 @@ sanitizePDF.title=Sanitizovat PDF sanitizePDF.header=Sanitizovat PDF soubor sanitizePDF.selectText.1=Odstranit JavaScript akce sanitizePDF.selectText.2=Odstranit vložené soubory -sanitizePDF.selectText.3=Odstranit metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Odstranit odkazy sanitizePDF.selectText.5=Odstranit písma +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanitizovat PDF diff --git a/src/main/resources/messages_da_DK.properties b/src/main/resources/messages_da_DK.properties index 4bf0f03a..6f83a491 100644 --- a/src/main/resources/messages_da_DK.properties +++ b/src/main/resources/messages_da_DK.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktive Brugere: adminUserSettings.disabledUsers=Deaktiverede Brugere: adminUserSettings.totalUsers=Samlet Antal Brugere: adminUserSettings.lastRequest=Seneste Anmodning +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Eksport database.header=Database Import/Eksport @@ -709,9 +733,10 @@ sanitizePDF.title=Rens PDF sanitizePDF.header=Rens en PDF-fil sanitizePDF.selectText.1=Fjern JavaScript-handlinger sanitizePDF.selectText.2=Fjern indlejrede filer -sanitizePDF.selectText.3=Fjern metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Fjern links sanitizePDF.selectText.5=Fjern skrifttyper +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Rens PDF diff --git a/src/main/resources/messages_de_DE.properties b/src/main/resources/messages_de_DE.properties index 7a01d327..b4433f25 100644 --- a/src/main/resources/messages_de_DE.properties +++ b/src/main/resources/messages_de_DE.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktive Benutzer: adminUserSettings.disabledUsers=Deaktivierte Benutzer: adminUserSettings.totalUsers=Gesamtzahl der Benutzer: adminUserSettings.lastRequest=Letzte Anfrage +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Datenbank Import/Export database.header=Datenbank Import/Export @@ -709,9 +733,10 @@ sanitizePDF.title=PDF Bereinigen sanitizePDF.header=PDF Bereinigen sanitizePDF.selectText.1=Javascript-Aktionen entfernen sanitizePDF.selectText.2=Eingebettete Dateien entfernen -sanitizePDF.selectText.3=Metadaten entfernen +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Links entfernen sanitizePDF.selectText.5=Schriftarten entfernen +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Bereinigen diff --git a/src/main/resources/messages_el_GR.properties b/src/main/resources/messages_el_GR.properties index 321bf745..d297c4af 100644 --- a/src/main/resources/messages_el_GR.properties +++ b/src/main/resources/messages_el_GR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Ενεργοί χρήστες: adminUserSettings.disabledUsers=Απενεργοποιημένοι χρήστες: adminUserSettings.totalUsers=Συνολικοί χρήστες: adminUserSettings.lastRequest=Τελευταίο αίτημα +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Εισαγωγή/Εξαγωγή βάσης δεδομένων database.header=Εισαγωγή/Εξαγωγή βάσης δεδομένων @@ -709,9 +733,10 @@ sanitizePDF.title=Εξυγίανση PDF sanitizePDF.header=Εξυγίανση αρχείου PDF sanitizePDF.selectText.1=Αφαίρεση ενεργειών JavaScript sanitizePDF.selectText.2=Αφαίρεση ενσωματωμένων αρχείων -sanitizePDF.selectText.3=Αφαίρεση μεταδεδομένων +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Αφαίρεση συνδέσμων sanitizePDF.selectText.5=Αφαίρεση γραμματοσειρών +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Εξυγίανση PDF diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 006e4e7a..a6be769f 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -231,33 +231,7 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request -adminUserSettings.userSessions=User sessions -adminUserSettings.totalSessions=Total Sessions: -adminUserSettings.usage=View Usage -endpointStatistics.title=Endpoint Statistics -endpointStatistics.header=Endpoint Statistics -endpointStatistics.top10=Top 10 -endpointStatistics.top20=Top 20 -endpointStatistics.all=All -endpointStatistics.refresh=Refresh -endpointStatistics.includeHomepage=Include Homepage ('/') -endpointStatistics.includeLoginPage=Include Login Page ('/login') -endpointStatistics.totalEndpoints=Total Endpoints -endpointStatistics.totalVisits=Total Visits -endpointStatistics.showing=Showing -endpointStatistics.selectedVisits=Selected Visits -endpointStatistics.endpoint=Endpoint -endpointStatistics.visits=Visits -endpointStatistics.percentage=Percentage -endpointStatistics.loading=Loading... -endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. -endpointStatistics.home=Home -endpointStatistics.login=Login -endpointStatistics.top=Top -endpointStatistics.numberOfVisits=Number of Visits -endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) -endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export diff --git a/src/main/resources/messages_en_US.properties b/src/main/resources/messages_en_US.properties index c5392d41..0c35d6c5 100644 --- a/src/main/resources/messages_en_US.properties +++ b/src/main/resources/messages_en_US.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export @@ -709,9 +733,10 @@ sanitizePDF.title=Sanitize PDF sanitizePDF.header=Sanitize a PDF file sanitizePDF.selectText.1=Remove JavaScript actions sanitizePDF.selectText.2=Remove embedded files -sanitizePDF.selectText.3=Remove metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Remove links sanitizePDF.selectText.5=Remove fonts +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanitize PDF diff --git a/src/main/resources/messages_es_ES.properties b/src/main/resources/messages_es_ES.properties index 2e0cbc5a..805a1201 100644 --- a/src/main/resources/messages_es_ES.properties +++ b/src/main/resources/messages_es_ES.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Usuarios Activos: adminUserSettings.disabledUsers=Usuarios deshabilitados: adminUserSettings.totalUsers=Usuarios totales: adminUserSettings.lastRequest=Última petición +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Base de Datos Importar/Exportar database.header=Base de Datos Importar/Exportar @@ -709,9 +733,10 @@ sanitizePDF.title=Limpiar archivo PDF sanitizePDF.header=Limpiar un archivo PDF sanitizePDF.selectText.1=Eliminar código JavaScript sanitizePDF.selectText.2=Eliminar archivos incrustados -sanitizePDF.selectText.3=Eliminar metadatos +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Eliminar enlaces sanitizePDF.selectText.5=Eliminar fuentes +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Limpiar PDF diff --git a/src/main/resources/messages_eu_ES.properties b/src/main/resources/messages_eu_ES.properties index e468adec..0ea3c6dc 100644 --- a/src/main/resources/messages_eu_ES.properties +++ b/src/main/resources/messages_eu_ES.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export @@ -709,9 +733,10 @@ sanitizePDF.title=PDF-a desinfektatu sanitizePDF.header=PDF fitxategi bat desinfektatu sanitizePDF.selectText.1=Ezabatu JavaScript akzioak sanitizePDF.selectText.2=Ezabatu embedded fitxategiak -sanitizePDF.selectText.3=Ezabatu metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Ezabatu esketak sanitizePDF.selectText.5=Ezabatu iturri letrak +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Desinfektatu PDF diff --git a/src/main/resources/messages_fa_IR.properties b/src/main/resources/messages_fa_IR.properties index bb8af544..5c14acab 100644 --- a/src/main/resources/messages_fa_IR.properties +++ b/src/main/resources/messages_fa_IR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=کاربران فعال: adminUserSettings.disabledUsers=کاربران غیرفعال: adminUserSettings.totalUsers=کل کاربران: adminUserSettings.lastRequest=آخرین درخواست +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=وارد کردن/صادر کردن پایگاه داده database.header=وارد کردن/صادر کردن پایگاه داده @@ -709,9 +733,10 @@ sanitizePDF.title=پاکسازی PDF sanitizePDF.header=پاکسازی یک فایل PDF sanitizePDF.selectText.1=حذف عملیات جاوااسکریپت sanitizePDF.selectText.2=حذف فایل‌های جاسازی شده -sanitizePDF.selectText.3=حذف متاداده‌ها +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=حذف لینک‌ها sanitizePDF.selectText.5=حذف فونت‌ها +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=پاکسازی PDF diff --git a/src/main/resources/messages_fr_FR.properties b/src/main/resources/messages_fr_FR.properties index e5500ffc..517b3941 100644 --- a/src/main/resources/messages_fr_FR.properties +++ b/src/main/resources/messages_fr_FR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Utilisateurs actifs : adminUserSettings.disabledUsers=Utilisateurs désactivés : adminUserSettings.totalUsers=Utilisateurs au total : adminUserSettings.lastRequest=Dernière requête +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Import/Export de la Base de Données database.header=Import/Export de la Base de Données @@ -709,9 +733,10 @@ sanitizePDF.title=Assainir sanitizePDF.header=Assainir sanitizePDF.selectText.1=Supprimer les actions JavaScript sanitizePDF.selectText.2=Supprimer les fichiers intégrés -sanitizePDF.selectText.3=Supprimer les métadonnées +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Supprimer les liens sanitizePDF.selectText.5=Supprimer les polices +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Assainir diff --git a/src/main/resources/messages_ga_IE.properties b/src/main/resources/messages_ga_IE.properties index dc0346cd..03fb4dc2 100644 --- a/src/main/resources/messages_ga_IE.properties +++ b/src/main/resources/messages_ga_IE.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Úsáideoirí Gníomhacha: adminUserSettings.disabledUsers=Úsáideoirí faoi mhíchumas: adminUserSettings.totalUsers=Úsáideoirí Iomlán: adminUserSettings.lastRequest=Iarratas Deiridh +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Iompórtáil/Easpórtáil Bunachar Sonraí database.header=Iompórtáil/Easpórtáil Bunachar Sonraí @@ -709,9 +733,10 @@ sanitizePDF.title=PDF sláintíocht sanitizePDF.header=Glanadh comhad PDF sanitizePDF.selectText.1=Bain gníomhartha JavaScript sanitizePDF.selectText.2=Bain comhaid leabaithe -sanitizePDF.selectText.3=Bain meiteashonraí +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Bain naisc sanitizePDF.selectText.5=Bain clónna +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF sláintíocht diff --git a/src/main/resources/messages_hi_IN.properties b/src/main/resources/messages_hi_IN.properties index b9d2b7cc..01a8c2f4 100644 --- a/src/main/resources/messages_hi_IN.properties +++ b/src/main/resources/messages_hi_IN.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=सक्रिय उपयोगकर्ता: adminUserSettings.disabledUsers=अक्षम उपयोगकर्ता: adminUserSettings.totalUsers=कुल उपयोगकर्ता: adminUserSettings.lastRequest=अंतिम अनुरोध +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=डेटाबेस आयात/निर्यात database.header=डेटाबेस आयात/निर्यात @@ -709,9 +733,10 @@ sanitizePDF.title=PDF सैनिटाइज़ करें sanitizePDF.header=PDF फ़ाइल सैनिटाइज़ करें sanitizePDF.selectText.1=जावास्क्रिप्ट क्रियाएं हटाएं sanitizePDF.selectText.2=एम्बेडेड फ़ाइलें हटाएं -sanitizePDF.selectText.3=मेटाडेटा हटाएं +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=लिंक हटाएं sanitizePDF.selectText.5=फ़ॉन्ट्स हटाएं +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF सैनिटाइज़ करें diff --git a/src/main/resources/messages_hr_HR.properties b/src/main/resources/messages_hr_HR.properties index 49b9da7b..8fd9ac9c 100644 --- a/src/main/resources/messages_hr_HR.properties +++ b/src/main/resources/messages_hr_HR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktivni korisnici: adminUserSettings.disabledUsers=Isključeni korisnici: adminUserSettings.totalUsers=Ukupan broj korisnika: adminUserSettings.lastRequest=Zadnji zahtjev +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export @@ -709,9 +733,10 @@ sanitizePDF.title=Sanirajte PDF sanitizePDF.header=Sanirajte PDF datoteku sanitizePDF.selectText.1=Ukloni JavaScript akcije sanitizePDF.selectText.2=Ukloni ugrađene datoteke -sanitizePDF.selectText.3=Ukloni metapodatke +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Ukloni poveznice sanitizePDF.selectText.5=Uklonite fontove +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanirajte PDF diff --git a/src/main/resources/messages_hu_HU.properties b/src/main/resources/messages_hu_HU.properties index a64e15ec..fe831bae 100644 --- a/src/main/resources/messages_hu_HU.properties +++ b/src/main/resources/messages_hu_HU.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktív felhasználók: adminUserSettings.disabledUsers=Letiltott felhasználók: adminUserSettings.totalUsers=Összes felhasználó: adminUserSettings.lastRequest=Utolsó kérés +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Adatbázis importálás/exportálás database.header=Adatbázis importálás/exportálás @@ -709,9 +733,10 @@ sanitizePDF.title=PDF tisztítása sanitizePDF.header=PDF fájl tisztítása sanitizePDF.selectText.1=JavaScript műveletek eltávolítása sanitizePDF.selectText.2=Beágyazott fájlok eltávolítása -sanitizePDF.selectText.3=Metaadatok eltávolítása +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Hivatkozások eltávolítása sanitizePDF.selectText.5=Betűtípusok eltávolítása +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF tisztítása diff --git a/src/main/resources/messages_id_ID.properties b/src/main/resources/messages_id_ID.properties index 2c6e65b3..c3020501 100644 --- a/src/main/resources/messages_id_ID.properties +++ b/src/main/resources/messages_id_ID.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Pengguna Aktif: adminUserSettings.disabledUsers=Pengguna Dinonaktifkan: adminUserSettings.totalUsers=Total Pengguna: adminUserSettings.lastRequest=Permintaan Terakhir +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Impor/Ekspor Database database.header=Impor/Ekspor Database @@ -709,9 +733,10 @@ sanitizePDF.title=Bersihkan PDF sanitizePDF.header=Membersihkan berkas PDF sanitizePDF.selectText.1=Hapus tindakan JavaScript sanitizePDF.selectText.2=Hapus berkas yang disematkan -sanitizePDF.selectText.3=Hapus metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Hapus tautan sanitizePDF.selectText.5=Hapus font +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Membersihkan PDF diff --git a/src/main/resources/messages_it_IT.properties b/src/main/resources/messages_it_IT.properties index 1ba2d0da..ebf62a29 100644 --- a/src/main/resources/messages_it_IT.properties +++ b/src/main/resources/messages_it_IT.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Utenti attivi: adminUserSettings.disabledUsers=Utenti disabili: adminUserSettings.totalUsers=Utenti totali: adminUserSettings.lastRequest=Ultima richiesta +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Importazione/Esportazione database database.header=Importazione/esportazione database @@ -709,9 +733,10 @@ sanitizePDF.title=Pulire PDF sanitizePDF.header=Pulisci un file PDF sanitizePDF.selectText.1=Rimuovi le azioni JavaScript sanitizePDF.selectText.2=Rimuovi i file incorporati -sanitizePDF.selectText.3=Rimuovi i metadati +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Rimuovi collegamenti sanitizePDF.selectText.5=Rimuovi i font +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Pulisci PDF diff --git a/src/main/resources/messages_ja_JP.properties b/src/main/resources/messages_ja_JP.properties index 79ad40c1..4e4b5bcc 100644 --- a/src/main/resources/messages_ja_JP.properties +++ b/src/main/resources/messages_ja_JP.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=アクティブユーザー: adminUserSettings.disabledUsers=無効なユーザー: adminUserSettings.totalUsers=ユーザー合計: adminUserSettings.lastRequest=最後のリクエスト +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=データベースのインポート/エクスポート database.header=データベースのインポート/エクスポート @@ -709,9 +733,10 @@ sanitizePDF.title=PDFをサニタイズ sanitizePDF.header=PDFファイルをサニタイズ sanitizePDF.selectText.1=JavaScriptアクションを削除 sanitizePDF.selectText.2=埋め込みファイルを削除 -sanitizePDF.selectText.3=メタデータを削除 +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=リンクを削除 sanitizePDF.selectText.5=フォントを削除 +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDFをサニタイズする diff --git a/src/main/resources/messages_ko_KR.properties b/src/main/resources/messages_ko_KR.properties index 00e8da56..2edd21e0 100644 --- a/src/main/resources/messages_ko_KR.properties +++ b/src/main/resources/messages_ko_KR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=활성 사용자: adminUserSettings.disabledUsers=비활성화된 사용자: adminUserSettings.totalUsers=전체 사용자: adminUserSettings.lastRequest=마지막 요청 +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=데이터베이스 가져오기/내보내기 database.header=데이터베이스 가져오기/내보내기 @@ -709,9 +733,10 @@ sanitizePDF.title=PDF 정리 sanitizePDF.header=PDF 파일 정리 sanitizePDF.selectText.1=JavaScript 작업 제거 sanitizePDF.selectText.2=임베디드 파일 제거 -sanitizePDF.selectText.3=메타데이터 제거 +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=링크 제거 sanitizePDF.selectText.5=글꼴 제거 +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF 정리 diff --git a/src/main/resources/messages_nl_NL.properties b/src/main/resources/messages_nl_NL.properties index 21514896..b7020c64 100644 --- a/src/main/resources/messages_nl_NL.properties +++ b/src/main/resources/messages_nl_NL.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Laatste aanvraag +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Importeer/Exporteer database.header=Database Importeer/Exporteer @@ -709,9 +733,10 @@ sanitizePDF.title=PDF opschonen sanitizePDF.header=Een PDF-bestand opschonen sanitizePDF.selectText.1=Verwijder Javascript-acties sanitizePDF.selectText.2=Verwijder ingebedde bestanden -sanitizePDF.selectText.3=Verwijder metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Verwijder links sanitizePDF.selectText.5=Verwijder lettertypen +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF opschonen diff --git a/src/main/resources/messages_no_NB.properties b/src/main/resources/messages_no_NB.properties index b2a22a18..8a327f16 100644 --- a/src/main/resources/messages_no_NB.properties +++ b/src/main/resources/messages_no_NB.properties @@ -3,8 +3,8 @@ ########### # the direction that the language is written (ltr = left to right, rtl = right to left) language.direction=ltr -addPageNumbers.fontSize=Font Size -addPageNumbers.fontName=Font Name +addPageNumbers.fontSize=Skriftstørrelse +addPageNumbers.fontName=Skrifttype pdfPrompt=Velg PDF(er) multiPdfPrompt=Velg PDF-filer (2+) multiPdfDropPrompt=Velg (eller dra og slipp) alle PDF-ene du trenger @@ -29,7 +29,7 @@ downloadPdf=Last ned PDF text=Tekst font=Skrifttype selectFillter=-- Velg -- -pageNum=Sidnummer +pageNum=Sidenummer sizes.small=Liten sizes.medium=Middels sizes.large=Stor @@ -56,12 +56,12 @@ userNotFoundMessage=Bruker ikke funnet. incorrectPasswordMessage=Nåværende passord er feil. usernameExistsMessage=Det nye brukernavnet eksisterer allerede. invalidUsernameMessage=Ugyldig brukernavn, brukernavnet kan bare inneholde bokstaver, tall og følgende spesialtegn @._+- eller må være en gyldig e-postadresse. -invalidPasswordMessage=The password must not be empty and must not have spaces at the beginning or end. +invalidPasswordMessage=Passordet kan ikke være tomt og må ikke ha mellomrom i begynnelsen eller slutten. confirmPasswordErrorMessage=Nytt passord og Bekreft nytt passord må være like. deleteCurrentUserMessage=Kan ikke slette den innloggede brukeren. deleteUsernameExistsMessage=Brukernavnet eksisterer ikke og kan ikke slettes. downgradeCurrentUserMessage=Kan ikke nedgradere den innloggede brukerens rolle. -disabledCurrentUserMessage=The current user cannot be disabled +disabledCurrentUserMessage=Den pålogga brukeren kan ikke deaktiveres. downgradeCurrentUserLongMessage=Kan ikke nedgradere den innloggede brukerens rolle. Derfor vil ikke den innloggede brukeren bli vist. userAlreadyExistsOAuthMessage=Brukeren eksisterer allerede som en OAuth2-bruker. userAlreadyExistsWebMessage=Brukeren eksisterer allerede som en web-bruker. @@ -77,18 +77,18 @@ color=Farge sponsor=Sponsor info=Info pro=Pro -page=Page -pages=Pages -loading=Loading... -addToDoc=Add to Document +page=Side +pages=Sider +loading=Laster... +addToDoc=Legg til i dokument reset=Reset apply=Apply -legal.privacy=Privacy Policy -legal.terms=Terms and Conditions -legal.accessibility=Accessibility -legal.cookie=Cookie Policy -legal.impressum=Impressum +legal.privacy=Personvernerklæring +legal.terms=Vilkår og betingelser +legal.accessibility=Tilgjengelighet +legal.cookie=Informasjonskapsler +legal.impressum=Juridisk informasjon ############### # Pipeline # @@ -100,7 +100,7 @@ pipeline.defaultOption=Tilpasset pipeline.submitButton=Send inn pipeline.help=Pipeline hjelp pipeline.scanHelp=Mappe skanning hjelp -pipeline.deletePrompt=Are you sure you want to delete pipeline +pipeline.deletePrompt=Er du sikker på at du vil slette denne pipelinen? ###################### # Pipeline Options # @@ -118,21 +118,21 @@ pipelineOptions.validateButton=Valider ######################## # ENTERPRISE EDITION # ######################## -enterpriseEdition.button=Upgrade to Pro -enterpriseEdition.warning=This feature is only available to Pro users. -enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features. -enterpriseEdition.ssoAdvert=Looking for more user management features? Check out Stirling PDF Pro +enterpriseEdition.button=Oppgrader til Pro +enterpriseEdition.warning=Denne funksjonen er kun tilgjengelig for Pro-brukere. +enterpriseEdition.yamlAdvert=Stirling PDF Pro støtter YAML-konfigurasjons filer og andre SSO funksjoner. +enterpriseEdition.ssoAdvert=Søker du etter flere administrerings funksjoner? Sjekk ut Stirling PDF Pro ################# # Analytics # ################# -analytics.title=Do you want make Stirling PDF better? -analytics.paragraph1=Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents. -analytics.paragraph2=Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better. -analytics.enable=Enable analytics -analytics.disable=Disable analytics -analytics.settings=You can change the settings for analytics in the config/settings.yml file +analytics.title=Vill du gjøre Stirling PDF bedre? +analytics.paragraph1=Stirling PDF har valgfri analyse for å hjelpe oss med å forbedre produktet. Vi sporer ikke personlig informasjon eller filinnhold. +analytics.paragraph2=Vennligst vurder å aktivere analyse for å hjelpe Stirling-PDF å vokse og for å la oss forstå brukerne våre bedre. +analytics.enable=Aktiver analyse +analytics.disable=Deaktiver analyse +analytics.settings=Du kan endre innstillingene for analyse i config/settings.yml filen ############# # NAVBAR # @@ -144,14 +144,14 @@ navbar.language=Språk navbar.settings=Innstillinger navbar.allTools=Verktøy navbar.multiTool=Multi Verktøy -navbar.search=Search +navbar.search=Søk navbar.sections.organize=Organisere navbar.sections.convertTo=Konverter til PDF navbar.sections.convertFrom=Konverter fra PDF navbar.sections.security=Signer & Sikkerhet navbar.sections.advance=Avansert navbar.sections.edit=Vis & Rediger -navbar.sections.popular=Popular +navbar.sections.popular=Populært ############# # SETTINGS # @@ -210,7 +210,7 @@ adminUserSettings.user=Bruker adminUserSettings.addUser=Legg til Ny Bruker adminUserSettings.deleteUser=Slett Bruker adminUserSettings.confirmDeleteUser=Skal brukeren slettes? -adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled? +adminUserSettings.confirmChangeUserStatus=Skal brukeren deaktiveres/aktiveres? adminUserSettings.usernameInfo=Brukernavn kan bare inneholde bokstaver, tall og følgende spesialtegn @._+- eller må være en gyldig e-postadresse. adminUserSettings.roles=Roller adminUserSettings.role=Rolle @@ -224,14 +224,38 @@ adminUserSettings.forceChange=Tving bruker til å endre passord ved innlogging adminUserSettings.submit=Lagre Bruker adminUserSettings.changeUserRole=Endre Brukerens Rolle adminUserSettings.authenticated=Autentisert -adminUserSettings.editOwnProfil=Edit own profile -adminUserSettings.enabledUser=enabled user -adminUserSettings.disabledUser=disabled user -adminUserSettings.activeUsers=Active Users: -adminUserSettings.disabledUsers=Disabled Users: -adminUserSettings.totalUsers=Total Users: -adminUserSettings.lastRequest=Last Request +adminUserSettings.editOwnProfil=Rediger din profil +adminUserSettings.enabledUser=aktivert bruker +adminUserSettings.disabledUser=deaktivert bruker +adminUserSettings.activeUsers=Aktive brukere: +adminUserSettings.disabledUsers=Deaktiverte brukere: +adminUserSettings.totalUsers=Totalt antall brukere: +adminUserSettings.lastRequest=Siste spørring +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Eksport database.header=Database Import/Eksport @@ -240,20 +264,20 @@ database.creationDate=Opprettelsesdato database.fileSize=Filstørrelse database.deleteBackupFile=Slett sikkerhetskopifil database.importBackupFile=Importer sikkerhetskopifil -database.createBackupFile=Create Backup File +database.createBackupFile=Lag sikkerhetskopifil database.downloadBackupFile=Last ned sikkerhetskopifil database.info_1=Når du importerer data, er det avgjørende å sikre riktig struktur. Hvis du er usikker på hva du gjør, bør du søke råd og støtte fra en profesjonell. En feil i strukturen kan føre til applikasjonsfeil, inkludert fullstendig manglende evne til å kjøre applikasjonen. database.info_2=Filnavnet spiller ingen rolle ved opplasting. Det vil bli omdøpt etterpå for å følge formatet backup_user_yyyyMMddHHmm.sql, for å sikre en konsekvent navnekonvensjon. database.submit=Importer sikkerhetskopi database.importIntoDatabaseSuccessed=Import til database vellykket -database.backupCreated=Database backup successful +database.backupCreated=Sikkerhetskopiering opprettet database.fileNotFound=Fil ikke funnet database.fileNullOrEmpty=Fil må ikke være tom eller null database.failedImportFile=Import av fil mislyktes -database.notSupported=This function is not available for your database connection. +database.notSupported=Denne funksjonen er ikke tilgjengelig for din databasetilkobling. -session.expired=Your session has expired. Please refresh the page and try again. -session.refreshPage=Refresh Page +session.expired=Økten din har utløpt. Vennligst oppdater siden og prøv igjen. +session.refreshPage=Oppdater Side ############# # HOME-PAGE # @@ -266,14 +290,14 @@ home.viewPdf.title=View/Edit PDF home.viewPdf.desc=Vis, annoter, legg til tekst eller bilder viewPdf.tags=vis,les,annoter,tekst,bilde -home.setFavorites=Set Favourites -home.hideFavorites=Hide Favourites -home.showFavorites=Show Favourites -home.legacyHomepage=Old homepage -home.newHomePage=Try our new homepage! -home.alphabetical=Alphabetical -home.globalPopularity=Global Popularity -home.sortBy=Sort by: +home.setFavorites=Angi Favoritter +home.hideFavorites=Skjul Favoritter +home.showFavorites=Vis Favoritter +home.legacyHomepage=Gammel hjemmeside +home.newHomePage=Prøv vår nye hjemmeside! +home.alphabetical=Alfabetisk +home.globalPopularity=Global Popularitet +home.sortBy=Sorter etter: home.multiTool.title=PDF Multi Verktøy home.multiTool.desc=Slå sammen, roter, omorganiser og fjern sider @@ -489,9 +513,9 @@ home.autoRedact.title=Automatisk Sensurering home.autoRedact.desc=Automatisk sensurering (sverter ut) tekst i en PDF basert på inntastet tekst autoRedact.tags=Sensurere,Skjule,sverte ut,svart,markør,skjult -home.redact.title=Manual Redaction -home.redact.desc=Redacts a PDF based on selected text, drawn shapes and/or selected page(s) -redact.tags=Redact,Hide,black out,black,marker,hidden,manual +home.redact.title=Manuell Sensurering +home.redact.desc=Sensurerer en PDF basert på valgt tekst, tegnede former og/eller valgte side(r) +redact.tags=Sensurere,Skjule,sverte ut,svart,markør,skjult,manuell home.tableExtraxt.title=PDF til CSV home.tableExtraxt.desc=Ekstraherer tabeller fra en PDF og konverterer dem til CSV @@ -516,37 +540,37 @@ home.AddStampRequest.desc=Legg til tekst eller bilde stempler på angitte steder AddStampRequest.tags=stempel,legg til bilde,senter bilde,vannmerke,PDF,embed,tilpass -home.removeImagePdf.title=Remove image -home.removeImagePdf.desc=Remove image from PDF to reduce file size -removeImagePdf.tags=Remove Image,Page operations,Back end,server side +home.removeImagePdf.title=Fjern bilde +home.removeImagePdf.desc=Fjern bilde fra PDF for å redusere filstørrelsen +removeImagePdf.tags=Fjern Bilde,Sideoperasjoner,Backend,serverside home.splitPdfByChapters.title=Split PDF by Chapters home.splitPdfByChapters.desc=Split a PDF into multiple files based on its chapter structure. splitPdfByChapters.tags=split,chapters,bookmarks,organize -home.validateSignature.title=Validate PDF Signature -home.validateSignature.desc=Verify digital signatures and certificates in PDF documents -validateSignature.tags=signature,verify,validate,pdf,certificate,digital signature,Validate Signature,Validate certificate +home.validateSignature.title=Valider PDF-signatur +home.validateSignature.desc=Verifiser digitale signaturer og sertifikater i PDF-dokumenter +validateSignature.tags=signatur,verifiser,valider,pdf,sertifikat,digital signatur,Valider signatur,Valider sertifikat #replace-invert-color -replace-color.title=Replace-Invert-Color -replace-color.header=Replace-Invert Color PDF -home.replaceColorPdf.title=Replace and Invert Color -home.replaceColorPdf.desc=Replace color for text and background in PDF and invert full color of pdf to reduce file size -replaceColorPdf.tags=Replace Color,Page operations,Back end,server side -replace-color.selectText.1=Replace or Invert color Options -replace-color.selectText.2=Default(Default high contrast colors) -replace-color.selectText.3=Custom(Customized colors) -replace-color.selectText.4=Full-Invert(Invert all colors) -replace-color.selectText.5=High contrast color options -replace-color.selectText.6=white text on black background -replace-color.selectText.7=Black text on white background -replace-color.selectText.8=Yellow text on black background -replace-color.selectText.9=Green text on black background -replace-color.selectText.10=Choose text Color -replace-color.selectText.11=Choose background Color -replace-color.submit=Replace +replace-color.title=Erstatt-Inverter-Farge +replace-color.header=Erstatt-Inverter Farge PDF +home.replaceColorPdf.title=Erstatt og Inverter Farge +home.replaceColorPdf.desc=Erstatt farge for tekst og bakgrunn i PDF og inverter full farge av pdf for å redusere filstørrelsen +replaceColorPdf.tags=Erstatt Farge,Sideoperasjoner,Backend,serverside +replace-color.selectText.1=Erstatt eller Inverter farge alternativer +replace-color.selectText.2=Standard(Standard høy kontrast farger) +replace-color.selectText.3=Tilpasset(Tilpassede farger) +replace-color.selectText.4=Full-Invertering(Inverter alle farger) +replace-color.selectText.5=Høy kontrast fargealternativer +replace-color.selectText.6=hvit tekst på svart bakgrunn +replace-color.selectText.7=Svart tekst på hvit bakgrunn +replace-color.selectText.8=Gul tekst på svart bakgrunn +replace-color.selectText.9=Grønn tekst på svart bakgrunn +replace-color.selectText.10=Velg tekstfarge +replace-color.selectText.11=Velg bakgrunnsfarge +replace-color.submit=Erstatt @@ -565,18 +589,18 @@ login.locked=Kontoen din har blitt låst. login.signinTitle=Vennligst logg inn login.ssoSignIn=Logg inn via Enkel Pålogging login.oAuth2AutoCreateDisabled=OAUTH2 Auto-Opretting av bruker deaktivert -login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator. +login.oAuth2AdminBlockedUser=Registrering eller pålogging for ikke-registrerte brukere er for øyeblikket blokkert. Vennligst kontakt administrator login.oauth2RequestNotFound=Autentiseringsforespørsel ikke funnet login.oauth2InvalidUserInfoResponse=Ugyldig brukerinforespons login.oauth2invalidRequest=Ugyldig forespørsel login.oauth2AccessDenied=Tilgang nektet login.oauth2InvalidTokenResponse=Ugyldig tokenrespons login.oauth2InvalidIdToken=Ugyldig Id Token -login.relyingPartyRegistrationNotFound=No relying party registration found -login.userIsDisabled=User is deactivated, login is currently blocked with this username. Please contact the administrator. -login.alreadyLoggedIn=You are already logged in to -login.alreadyLoggedIn2=devices. Please log out of the devices and try again. -login.toManySessions=You have too many active sessions +login.relyingPartyRegistrationNotFound=Ingen konfigurasjon funnet for Relying Party" +login.userIsDisabled=Bruker er deaktivert, innlogging er for øyeblikket blokkert med dette brukernavnet. Vennligst kontakt administrator +login.alreadyLoggedIn=Du er allerede innlogget på +login.alreadyLoggedIn2=enheter. Logg ut og forsøk igjen +login.toManySessions=Du har for mange aktive økter #auto-redact autoRedact.title=Automatisk Sensurering @@ -591,31 +615,31 @@ autoRedact.convertPDFToImageLabel=Konverter PDF til PDF-bilde (Brukes for å fje autoRedact.submitButton=Send inn #redact -redact.title=Manual Redaction -redact.header=Manual Redaction -redact.submit=Redact -redact.textBasedRedaction=Text based Redaction -redact.pageBasedRedaction=Page-based Redaction -redact.convertPDFToImageLabel=Convert PDF to PDF-Image (Used to remove text behind the box) -redact.pageRedactionNumbers.title=Pages -redact.pageRedactionNumbers.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1) -redact.redactionColor.title=Redaction Color -redact.export=Export -redact.upload=Upload -redact.boxRedaction=Box draw redaction +redact.title=Manuell Sensurering +redact.header=Manuell Sensurering +redact.submit=Sensurer +redact.textBasedRedaction=Tekstbasert sensurering +redact.pageBasedRedaction=Sidebasert sensurering +redact.convertPDFToImageLabel=Konverter PDF til PDF-bilde (Brukes for å fjerne tekst bak boksen) +redact.pageRedactionNumbers.title=Sider +redact.pageRedactionNumbers.placeholder=(f.eks. 1,2,8 eller 4,7,12-16 eller 2n-1) +redact.redactionColor.title=Sensureringsfarge +redact.export=Eksporter +redact.upload=Last opp +redact.boxRedaction=Tegn sensureringsboks redact.zoom=Zoom -redact.zoomIn=Zoom in -redact.zoomOut=Zoom out -redact.nextPage=Next Page -redact.previousPage=Previous Page -redact.toggleSidebar=Toggle Sidebar -redact.showThumbnails=Show Thumbnails -redact.showDocumentOutline=Show Document Outline (double-click to expand/collapse all items) -redact.showAttatchments=Show Attachments -redact.showLayers=Show Layers (double-click to reset all layers to the default state) -redact.colourPicker=Colour Picker -redact.findCurrentOutlineItem=Find current outline item -redact.applyChanges=Apply Changes +redact.zoomIn=Zoom inn +redact.zoomOut=Zoom ut +redact.nextPage=Neste side +redact.previousPage=Forrige side +redact.toggleSidebar=Vis/skjul sidepanel +redact.showThumbnails=Vis miniatyrbilder +redact.showDocumentOutline=Vis dokumentstruktur (dobbeltklikk for å utvide/skjule alle elementer) +redact.showAttatchments=Vis vedlegg +redact.showLayers=Vis lag (dobbeltklikk for å tilbakestille alle lag til standardtilstand) +redact.colourPicker=Fargevelger +redact.findCurrentOutlineItem=Finn gjeldende punkt i strukturen +redact.applyChanges=Bruk endringer #showJS showJS.title=Vis Javascript @@ -709,9 +733,10 @@ sanitizePDF.title=Rensker PDF sanitizePDF.header=Rensker en PDF fil sanitizePDF.selectText.1=Fjern JavaScript-handlinger sanitizePDF.selectText.2=Fjern innebygde filer -sanitizePDF.selectText.3=Fjern metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Fjern lenker sanitizePDF.selectText.5=Fjern skrifter +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Rensk PDF @@ -836,9 +861,9 @@ compare.highlightColor.2=Uthevingsfarge 2: compare.document.1=Dokument 1 compare.document.2=Dokument 2 compare.submit=Sammenlign -compare.complex.message=One or both of the provided documents are large files, accuracy of comparison may be reduced -compare.large.file.message=One or Both of the provided documents are too large to process -compare.no.text.message=One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison. +compare.complex.message=Ett eller begge av de angitte dokumentene er store filer, nøyaktigheten av sammenligningen kan bli redusert +compare.large.file.message=Ett eller begge av de angitte dokumentene er for store til å behandle +compare.no.text.message=En eller begge av de valgte PDF-ene har ingen tekstinnhold. Vennligst velg PDF-er med tekst for sammenligning. #sign sign.title=Signer @@ -848,20 +873,20 @@ sign.draw=Tegn signatur sign.text=Tekstinput sign.clear=Slett sign.add=Legg til -sign.saved=Saved Signatures -sign.save=Save Signature -sign.personalSigs=Personal Signatures -sign.sharedSigs=Shared Signatures -sign.noSavedSigs=No saved signatures found -sign.addToAll=Add to all pages -sign.delete=Delete -sign.first=First page -sign.last=Last page -sign.next=Next page -sign.previous=Previous page -sign.maintainRatio=Toggle maintain aspect ratio -sign.undo=Undo -sign.redo=Redo +sign.saved=Lagrede signaturer +sign.save=Lagre signatur +sign.personalSigs=Personlige signaturer +sign.sharedSigs=Delte signaturer +sign.noSavedSigs=Ingen lagrede signaturer funnet +sign.addToAll=Legg til på alle sider +sign.delete=Slett +sign.first=Første side +sign.last=Siste side +sign.next=Neste side +sign.previous=Forrige side +sign.maintainRatio=Bytt behold sideforhold +sign.undo=Angre +sign.redo=Gjør om #repair repair.title=Reparer @@ -887,7 +912,7 @@ ScannerImageSplit.selectText.7=Minimumskonturområde: ScannerImageSplit.selectText.8=Angir minimumskonturområde terskel for et bilde ScannerImageSplit.selectText.9=Kantstørrelse: ScannerImageSplit.selectText.10=Angir størrelsen på kanten som legges til og fjernes for å forhindre hvite kanter i utdataen (standard: 1). -ScannerImageSplit.info=Python is not installed. It is required to run. +ScannerImageSplit.info=Python er ikke installert. Det er påkrevd for å kjøre. #OCR @@ -1333,43 +1358,43 @@ fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here fileChooser.extractPDF=Extracting... #release notes -releases.footer=Releases -releases.title=Release Notes -releases.header=Release Notes -releases.current.version=Current Release -releases.note=Release notes are only available in English +releases.footer=Versjoner +releases.title=Versjonsnotater +releases.header=Versjonsnotater +releases.current.version=Gjeldende Versjon +releases.note=Versjonsnotater er kun tilgjengelige på engelsk #Validate Signature -validateSignature.title=Validate PDF Signatures -validateSignature.header=Validate Digital Signatures -validateSignature.selectPDF=Select signed PDF file -validateSignature.submit=Validate Signatures -validateSignature.results=Validation Results +validateSignature.title=Valider PDF-signaturer +validateSignature.header=Valider Digitale Signaturer +validateSignature.selectPDF=Velg signert PDF-fil +validateSignature.submit=Valider Signaturer +validateSignature.results=Valideringsresultater validateSignature.status=Status -validateSignature.signer=Signer -validateSignature.date=Date -validateSignature.reason=Reason -validateSignature.location=Location -validateSignature.noSignatures=No digital signatures found in this document -validateSignature.status.valid=Valid -validateSignature.status.invalid=Invalid -validateSignature.chain.invalid=Certificate chain validation failed - cannot verify signer's identity -validateSignature.trust.invalid=Certificate not in trust store - source cannot be verified -validateSignature.cert.expired=Certificate has expired -validateSignature.cert.revoked=Certificate has been revoked -validateSignature.signature.info=Signature Information -validateSignature.signature=Signature -validateSignature.signature.mathValid=Signature is mathematically valid BUT: -validateSignature.selectCustomCert=Custom Certificate File X.509 (Optional) -validateSignature.cert.info=Certificate Details -validateSignature.cert.issuer=Issuer -validateSignature.cert.subject=Subject -validateSignature.cert.serialNumber=Serial Number -validateSignature.cert.validFrom=Valid From -validateSignature.cert.validUntil=Valid Until -validateSignature.cert.algorithm=Algorithm -validateSignature.cert.keySize=Key Size -validateSignature.cert.version=Version -validateSignature.cert.keyUsage=Key Usage -validateSignature.cert.selfSigned=Self-Signed +validateSignature.signer=Signatar +validateSignature.date=Dato +validateSignature.reason=Årsak +validateSignature.location=Sted +validateSignature.noSignatures=Ingen digitale signaturer funnet i dette dokumentet +validateSignature.status.valid=Gyldig +validateSignature.status.invalid=Ugyldig +validateSignature.chain.invalid=Validering av sertifikatkjede feilet - kan ikke verifisere signatarens identitet +validateSignature.trust.invalid=Sertifikatet er ikke i tillitslager - kilden kan ikke verifiseres +validateSignature.cert.expired=Sertifikatet har utløpt +validateSignature.cert.revoked=Sertifikatet har blitt tilbakekalt +validateSignature.signature.info=Signaturinformasjon +validateSignature.signature=Signatur +validateSignature.signature.mathValid=Signaturen er matematisk gyldig MEN: +validateSignature.selectCustomCert=Tilpasset Sertifikatfil X.509 (Valgfritt) +validateSignature.cert.info=Sertifikatdetaljer +validateSignature.cert.issuer=Utsteder +validateSignature.cert.subject=Emne +validateSignature.cert.serialNumber=Serienummer +validateSignature.cert.validFrom=Gyldig Fra +validateSignature.cert.validUntil=Gyldig Til +validateSignature.cert.algorithm=Algoritme +validateSignature.cert.keySize=Nøkkelstørrelse +validateSignature.cert.version=Versjon +validateSignature.cert.keyUsage=Nøkkelbruk +validateSignature.cert.selfSigned=Selv-signert validateSignature.cert.bits=bits diff --git a/src/main/resources/messages_pl_PL.properties b/src/main/resources/messages_pl_PL.properties index 506bec17..90145a48 100644 --- a/src/main/resources/messages_pl_PL.properties +++ b/src/main/resources/messages_pl_PL.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktywni Użytkownicy: adminUserSettings.disabledUsers=Wyłączeni Użytkownicy: adminUserSettings.totalUsers=Łączna Liczba Użytkowników: adminUserSettings.lastRequest=Ostatnie Zgłoszenie +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Import/Eksport bazy danych database.header=Import/Eksport bazy danych @@ -709,9 +733,10 @@ sanitizePDF.title=Dezynfekuj PDF sanitizePDF.header=Dezynfekuj dokument PDF sanitizePDF.selectText.1=Usuń elementy JavaScript sanitizePDF.selectText.2=Usuń załączone pliki -sanitizePDF.selectText.3=Usuń metadane +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Usuń linki sanitizePDF.selectText.5=Usuń czcionki +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Dezynfekuj PDF diff --git a/src/main/resources/messages_pt_BR.properties b/src/main/resources/messages_pt_BR.properties index 483cf54a..171d2d67 100644 --- a/src/main/resources/messages_pt_BR.properties +++ b/src/main/resources/messages_pt_BR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Usuários Ativos: adminUserSettings.disabledUsers=Usuários Desabilitados: adminUserSettings.totalUsers=Total de Usuários: adminUserSettings.lastRequest=Última solicitação +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Importar/Exportar banco de dados database.header=Importar/Exportar banco de dados @@ -709,9 +733,10 @@ sanitizePDF.title=Higienizar sanitizePDF.header=Higienizar sanitizePDF.selectText.1=Remover scripts de JavaScript. sanitizePDF.selectText.2=Remover arquivos embutidos. -sanitizePDF.selectText.3=Remover metadados. +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Remover links. sanitizePDF.selectText.5=Remover fontes. +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Higienizar PDF diff --git a/src/main/resources/messages_pt_PT.properties b/src/main/resources/messages_pt_PT.properties index f83f36f6..90702aa7 100644 --- a/src/main/resources/messages_pt_PT.properties +++ b/src/main/resources/messages_pt_PT.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Utilizadores Ativos: adminUserSettings.disabledUsers=Utilizadores Desativados: adminUserSettings.totalUsers=Total de Utilizadores: adminUserSettings.lastRequest=Último Pedido +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Importar/Exportar Base de Dados database.header=Importar/Exportar Base de Dados @@ -709,9 +733,10 @@ sanitizePDF.title=Sanitizar PDF sanitizePDF.header=Sanitizar um ficheiro PDF sanitizePDF.selectText.1=Remover ações JavaScript sanitizePDF.selectText.2=Remover ficheiros incorporados -sanitizePDF.selectText.3=Remover metadados +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Remover ligações sanitizePDF.selectText.5=Remover tipos de letra +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanitizar PDF diff --git a/src/main/resources/messages_ro_RO.properties b/src/main/resources/messages_ro_RO.properties index 0685c95a..c40b1572 100644 --- a/src/main/resources/messages_ro_RO.properties +++ b/src/main/resources/messages_ro_RO.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Utilizatori Activi: adminUserSettings.disabledUsers=Utilizatori Dezactivați: adminUserSettings.totalUsers=Total Utilizatori: adminUserSettings.lastRequest=Ultima Cerere +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Import/Export Bază de Date database.header=Import/Export Bază de Date @@ -709,9 +733,10 @@ sanitizePDF.title=Igienizează PDF sanitizePDF.header=Igienizează un fișier PDF sanitizePDF.selectText.1=Elimină acțiunile JavaScript sanitizePDF.selectText.2=Elimină fișierele încorporate -sanitizePDF.selectText.3=Elimină metadatele +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Elimină link-urile sanitizePDF.selectText.5=Elimină fonturile +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Igienizează PDF diff --git a/src/main/resources/messages_ru_RU.properties b/src/main/resources/messages_ru_RU.properties index 1a9460bf..5df70b61 100644 --- a/src/main/resources/messages_ru_RU.properties +++ b/src/main/resources/messages_ru_RU.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Активные пользователи: adminUserSettings.disabledUsers=Отключенные пользователи: adminUserSettings.totalUsers=Всего пользователей: adminUserSettings.lastRequest=Последний запрос +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Импорт/экспорт базы данных database.header=Импорт/экспорт базы данных @@ -709,9 +733,10 @@ sanitizePDF.title=Очистить PDF sanitizePDF.header=Очистить PDF-файл sanitizePDF.selectText.1=Удалить JavaScript-действия sanitizePDF.selectText.2=Удалить встроенные файлы -sanitizePDF.selectText.3=Удалить метаданные +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Удалить ссылки sanitizePDF.selectText.5=Удалить шрифты +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Очистить PDF diff --git a/src/main/resources/messages_sk_SK.properties b/src/main/resources/messages_sk_SK.properties index 8e6dc085..861962f1 100644 --- a/src/main/resources/messages_sk_SK.properties +++ b/src/main/resources/messages_sk_SK.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export @@ -709,9 +733,10 @@ sanitizePDF.title=Vyčistiť PDF sanitizePDF.header=Vyčistiť PDF súbor sanitizePDF.selectText.1=Odstrániť JavaScript akcie sanitizePDF.selectText.2=Odstrániť vložené súbory -sanitizePDF.selectText.3=Odstrániť metadáta +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Odstrániť odkazy sanitizePDF.selectText.5=Odstrániť fonty +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Vyčistiť PDF diff --git a/src/main/resources/messages_sl_SI.properties b/src/main/resources/messages_sl_SI.properties index b30b6db7..4e90efbb 100644 --- a/src/main/resources/messages_sl_SI.properties +++ b/src/main/resources/messages_sl_SI.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktivni uporabniki: adminUserSettings.disabledUsers=Onemogočeni uporabniki: adminUserSettings.totalUsers=Skupno število uporabnikov: adminUserSettings.lastRequest=Zadnja zahteva +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Uvoz/izvoz baze podatkov database.header=Uvoz/izvoz baze podatkov @@ -709,9 +733,10 @@ sanitizePDF.title=Prečisti PDF sanitizePDF.header=Prečisti datoteko PDF sanitizePDF.selectText.1=Odstrani dejanja JavaScript sanitizePDF.selectText.2=Odstrani vdelane datoteke -sanitizePDF.selectText.3=Odstrani metapodatke +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Odstrani povezave sanitizePDF.selectText.5=Odstrani pisave +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Prečisti PDF diff --git a/src/main/resources/messages_sr_LATN_RS.properties b/src/main/resources/messages_sr_LATN_RS.properties index 0a0cdfbb..3ace947d 100644 --- a/src/main/resources/messages_sr_LATN_RS.properties +++ b/src/main/resources/messages_sr_LATN_RS.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export @@ -709,9 +733,10 @@ sanitizePDF.title=Sanitizacija PDF-a sanitizePDF.header=Sanitizacija PDF fajla sanitizePDF.selectText.1=Ukloni JavaScript akcije sanitizePDF.selectText.2=Ukloni ugrađene fajlove -sanitizePDF.selectText.3=Ukloni metapodatke +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Ukloni linkove sanitizePDF.selectText.5=Ukloni fontove +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanitizuj PDF diff --git a/src/main/resources/messages_sv_SE.properties b/src/main/resources/messages_sv_SE.properties index 6a5d6d71..3df4ca0d 100644 --- a/src/main/resources/messages_sv_SE.properties +++ b/src/main/resources/messages_sv_SE.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktiva användare: adminUserSettings.disabledUsers=Inaktiverade användare: adminUserSettings.totalUsers=Totalt antal användare: adminUserSettings.lastRequest=Senaste begäran +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Databasimport/export database.header=Databasimport/export @@ -709,9 +733,10 @@ sanitizePDF.title=Sanera PDF sanitizePDF.header=Sanera en PDF-fil sanitizePDF.selectText.1=Ta bort JavaScript-åtgärder sanitizePDF.selectText.2=Ta bort inbäddade filer -sanitizePDF.selectText.3=Ta bort metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Ta bort länkar sanitizePDF.selectText.5=Ta bort typsnitt +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Sanera PDF diff --git a/src/main/resources/messages_th_TH.properties b/src/main/resources/messages_th_TH.properties index 372f3c38..f0a88484 100644 --- a/src/main/resources/messages_th_TH.properties +++ b/src/main/resources/messages_th_TH.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=ผู้ใช้ที่มีการใช adminUserSettings.disabledUsers=ผู้ใช้ที่ถูกระงับการใช้งาน: adminUserSettings.totalUsers=ผู้ใช้รวมทั้งหมด: adminUserSettings.lastRequest=การขอข้อมูลล่าสุด +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=การนำเข้า/ส่งออกฐานข้อมูล database.header=การนำเข้า/ส่งออกฐานข้อมูล @@ -709,9 +733,10 @@ sanitizePDF.title=ทำความสะอาด PDF sanitizePDF.header=ทำความสะอาดไฟล์ PDF sanitizePDF.selectText.1=ลบการกระทำ JavaScript sanitizePDF.selectText.2=ลบไฟล์ฝังตัว -sanitizePDF.selectText.3=ลบข้อมูลเมตา +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=ลบลิงก์ sanitizePDF.selectText.5=ลบฟอนต์ +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=ทำความสะอาด PDF diff --git a/src/main/resources/messages_tr_TR.properties b/src/main/resources/messages_tr_TR.properties index adb9644d..011fd486 100644 --- a/src/main/resources/messages_tr_TR.properties +++ b/src/main/resources/messages_tr_TR.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Aktif Kullanıcılar: adminUserSettings.disabledUsers=Devre Dışı Kullanıcılar: adminUserSettings.totalUsers=Toplam Kullanıcılar: adminUserSettings.lastRequest=Son İstek +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Veri Tabanını İçe/Dışa Aktar database.header=Veri Tabanını İçe/Dışa Aktar @@ -709,9 +733,10 @@ sanitizePDF.title=PDF'i Temizle sanitizePDF.header=PDF dosyasını temizle sanitizePDF.selectText.1=JavaScript işlemlerini kaldır sanitizePDF.selectText.2=Gömülü dosyaları kaldır -sanitizePDF.selectText.3=Üst veriyi kaldır +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Linkleri kaldır sanitizePDF.selectText.5=Fontları kaldır +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF'i Temizle diff --git a/src/main/resources/messages_uk_UA.properties b/src/main/resources/messages_uk_UA.properties index 86199c48..2dc40e30 100644 --- a/src/main/resources/messages_uk_UA.properties +++ b/src/main/resources/messages_uk_UA.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Активні користувачі: adminUserSettings.disabledUsers=Заблоковані користувачі: adminUserSettings.totalUsers=Всього користувачів: adminUserSettings.lastRequest=Останній запит +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Імпорт/експорт бази даних database.header=Імпорт/експорт бази даних @@ -709,9 +733,10 @@ sanitizePDF.title=Дезінфекція PDF sanitizePDF.header=Дезінфекція PDF файлу sanitizePDF.selectText.1=Видалити JavaScript sanitizePDF.selectText.2=Видалити вбудовані файли -sanitizePDF.selectText.3=Видалити метадані +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Видалити посилання sanitizePDF.selectText.5=Видалити шрифти +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Дезінфекція diff --git a/src/main/resources/messages_vi_VN.properties b/src/main/resources/messages_vi_VN.properties index 8695969c..8bfadca6 100644 --- a/src/main/resources/messages_vi_VN.properties +++ b/src/main/resources/messages_vi_VN.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Nhập/Xuất cơ sở dữ liệu database.header=Nhập/Xuất cơ sở dữ liệu @@ -709,9 +733,10 @@ sanitizePDF.title=Làm sạch PDF sanitizePDF.header=Làm sạch tệp PDF sanitizePDF.selectText.1=Xóa các hành động JavaScript sanitizePDF.selectText.2=Xóa các tệp nhúng -sanitizePDF.selectText.3=Xóa metadata +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=Xóa liên kết sanitizePDF.selectText.5=Xóa phông chữ +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=Làm sạch PDF diff --git a/src/main/resources/messages_zh_BO.properties b/src/main/resources/messages_zh_BO.properties index 8e768c30..e18bb02d 100644 --- a/src/main/resources/messages_zh_BO.properties +++ b/src/main/resources/messages_zh_BO.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=འགུལ་བཞིན་པའི་སྤ adminUserSettings.disabledUsers=སྤྱོད་མི་ཆོག་པའི་སྤྱོད་མཁན། adminUserSettings.totalUsers=སྤྱོད་མཁན་ཁྱོན་བསྡོམས། adminUserSettings.lastRequest=རེ་ཞུ་མཐའ་མ། +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=གཞི་གྲངས་མཛོད་ནང་འདྲེན་/ཕྱིར་འདྲེན། database.header=གཞི་གྲངས་མཛོད་ནང་འདྲེན་/ཕྱིར་འདྲེན། @@ -709,9 +733,10 @@ sanitizePDF.title=PDF གཙང་སེལ། sanitizePDF.header=PDF ཡིག་ཆ་གཙང་སེལ། sanitizePDF.selectText.1=Javascript བྱ་འགུལ་སུབ་པ། sanitizePDF.selectText.2=ནང་འཇུག་ཡིག་ཆ་སུབ་པ། -sanitizePDF.selectText.3=གནས་ཆ་སུབ་པ། +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=འབྲེལ་ཐག་སུབ་པ། sanitizePDF.selectText.5=ཡིག་གཟུགས་སུབ་པ། +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=PDF གཙང་སེལ། diff --git a/src/main/resources/messages_zh_CN.properties b/src/main/resources/messages_zh_CN.properties index af60eabc..8746feea 100644 --- a/src/main/resources/messages_zh_CN.properties +++ b/src/main/resources/messages_zh_CN.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=激活用户: adminUserSettings.disabledUsers=禁用用户: adminUserSettings.totalUsers=总用户: adminUserSettings.lastRequest=最后登录 +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=数据库 导入/导出 database.header=数据库 导入/导出 @@ -709,9 +733,10 @@ sanitizePDF.title=清理 PDF sanitizePDF.header=清理 PDF 文件 sanitizePDF.selectText.1=移除 JavaScript 操作 sanitizePDF.selectText.2=移除嵌入的文件 -sanitizePDF.selectText.3=移除元数据 +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=移除链接 sanitizePDF.selectText.5=移除字体 +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=清理PDF diff --git a/src/main/resources/messages_zh_TW.properties b/src/main/resources/messages_zh_TW.properties index 6a4bd2ee..4342b530 100644 --- a/src/main/resources/messages_zh_TW.properties +++ b/src/main/resources/messages_zh_TW.properties @@ -231,7 +231,31 @@ adminUserSettings.activeUsers=使用中的使用者: adminUserSettings.disabledUsers=已停用的使用者: adminUserSettings.totalUsers=使用者總數: adminUserSettings.lastRequest=最後請求時間 +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=資料庫匯入/匯出 database.header=資料庫匯入/匯出 @@ -709,9 +733,10 @@ sanitizePDF.title=清理 PDF sanitizePDF.header=清理 PDF 檔案 sanitizePDF.selectText.1=移除 JavaScript 操作 sanitizePDF.selectText.2=移除內嵌文件 -sanitizePDF.selectText.3=移除中繼資料 +sanitizePDF.selectText.3=Remove XMP metadata sanitizePDF.selectText.4=移除連結 sanitizePDF.selectText.5=移除字型 +sanitizePDF.selectText.6=Remove Document Info Metadata sanitizePDF.submit=清理 PDF diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 2bcc0df5..b7306861 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -61,15 +61,17 @@ security: privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair -enterpriseEdition: - enabled: false # set to 'true' to enable enterprise edition + +premium: key: 00000000-0000-0000-0000-000000000000 - SSOAutoLogin: false # Enable to auto login to first provided SSO - CustomMetadata: - autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values - author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username - creator: Stirling-PDF # supports text such as 'Company-PDF' - producer: Stirling-PDF # supports text such as 'Company-PDF' + enabled: false # Enable license key checks for pro/enterprise features + proFeatures: + SSOAutoLogin: false + CustomMetadata: + autoUpdateMetadata: false + author: username + creator: Stirling-PDF + producer: Stirling-PDF legal: termsAndConditions: https://www.stirlingpdf.com/terms-and-conditions # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder @@ -87,6 +89,7 @@ system: customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true + enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) datasource: enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration diff --git a/src/main/resources/static/3rdPartyLicenses.json b/src/main/resources/static/3rdPartyLicenses.json index 62019e13..89cda3fd 100644 --- a/src/main/resources/static/3rdPartyLicenses.json +++ b/src/main/resources/static/3rdPartyLicenses.json @@ -571,6 +571,49 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "io.micrometer:micrometer-registry-prometheus", + "moduleUrl": "https://github.com/micrometer-metrics/micrometer", + "moduleVersion": "1.14.5", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "io.prometheus:prometheus-metrics-config", + "moduleVersion": "1.3.6", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "io.prometheus:prometheus-metrics-core", + "moduleVersion": "1.3.6", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "io.prometheus:prometheus-metrics-exposition-formats", + "moduleVersion": "1.3.6", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "io.prometheus:prometheus-metrics-exposition-textformats", + "moduleVersion": "1.3.6", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "io.prometheus:prometheus-metrics-model", + "moduleVersion": "1.3.6", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "moduleName": "io.prometheus:prometheus-metrics-tracer-common", + "moduleVersion": "1.3.6", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "io.smallrye:jandex", "moduleVersion": "3.2.0", diff --git a/src/main/resources/static/css/usage.css b/src/main/resources/static/css/usage.css new file mode 100644 index 00000000..e4e7c1c3 --- /dev/null +++ b/src/main/resources/static/css/usage.css @@ -0,0 +1,83 @@ +.active-user { + color: green; + text-shadow: 0 0 5px green; +} + +.text-overflow { + max-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chart-container { + position: relative; + height: 400px; + width: 100%; + margin-bottom: 20px; +} + +.stats-box { + background: var(--md-sys-color-outline-variant); + padding: .8rem; + margin: 10px 0; + border-radius: 2rem; + text-align: center; +} + +.chart-controls { + display: flex; + justify-content: center; + gap: 10px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.filter-controls { + display: flex; + justify-content: center; + gap: 15px; + margin-top: 15px; + flex-wrap: wrap; +} + +.filter-checkbox { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; +} + +.filter-checkbox input { + margin-right: 5px; +} + +.loading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +html[data-bs-theme="light"] .loading { + background-color: rgba(255, 255, 255, 0.7); +} + +html[data-bs-theme="dark"] .loading { + background-color: rgba(15, 20, 26, 0.7); +} + +/* Add some animation to the refresh button */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.refreshing .material-symbols-rounded { + animation: spin 1s linear infinite; +} \ No newline at end of file diff --git a/src/main/resources/static/js/thirdParty/chart.umd.min.js b/src/main/resources/static/js/thirdParty/chart.umd.min.js new file mode 100644 index 00000000..9d6df8e4 --- /dev/null +++ b/src/main/resources/static/js/thirdParty/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.8/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.8 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=J(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=J(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function Hi(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const xs=new Map;let bs=0;function _s(){const t=window.devicePixelRatio;t!==bs&&(bs=t,xs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){xs.size||window.addEventListener("resize",_s),xs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){xs.delete(t),xs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class xn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=bn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=bn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function bn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.8";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new xn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(bt.listen(this,"complete",wn),bt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=Xn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=Z(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class bo extends xo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=xo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){xo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:bo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Ko(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Ko(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=qo(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const xa=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ba extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=xa(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=xa(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=bt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/src/main/resources/static/js/usage.js b/src/main/resources/static/js/usage.js new file mode 100644 index 00000000..a9c98ed9 --- /dev/null +++ b/src/main/resources/static/js/usage.js @@ -0,0 +1,363 @@ +// We'll fetch data from the API instead of hardcoding it +let allEndpointData = []; +let filteredData = []; + +// We'll store these as global variables that get updated when we fetch data +let sortedData = []; +let totalEndpoints = 0; +let totalVisits = 0; + +// Chart instance +let myChart; + + +// Function to get chart colors based on current theme +function getChartColors() { + var style = window.getComputedStyle(document.body) + + const colours = { + textColor: style.getPropertyValue('--md-sys-color-on-surface') , + primaryColor: style.getPropertyValue('--md-sys-color-primary'), + backgroundColor: style.getPropertyValue('--md-sys-color-background'), + gridColor: style.getPropertyValue('--md-sys-color-on-surface'), + tooltipBgColor: style.getPropertyValue('--md-sys-color-inverse-on-surface'), + tooltipTextColor: style.getPropertyValue('--md-sys-color-inverse-surface') + } + return colours; +} + +// Watch for theme changes and update chart if needed +function setupThemeChangeListener() { + + // Start observing theme changes + document.addEventListener("modeChanged", (event) => { + setTimeout(function() { + if (myChart) { + const currentLimit = document.getElementById('currentlyShowing').textContent; + const limit = (currentLimit === endpointStatsTranslations.all) + ? filteredData.length + : (currentLimit === endpointStatsTranslations.top20 ? 20 : 10); + updateChart(limit); + } + }, 100); + }); + + // Also watch for system preference changes + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', () => { + if (myChart) { + const currentLimit = document.getElementById('currentlyShowing').textContent; + const limit = (currentLimit === endpointStatsTranslations.all) + ? filteredData.length + : (currentLimit === endpointStatsTranslations.top20 ? 20 : 10); + updateChart(limit); + } + }); +} + +// Function to filter data based on checkbox settings +function filterData() { + const includeHome = document.getElementById('hideHomeCheckbox').checked; + const includeLogin = document.getElementById('hideLoginCheckbox').checked; + + filteredData = allEndpointData.filter(item => { + if (!includeHome && item.endpoint === '/') return false; + if (!includeLogin && item.endpoint === '/login') return false; + return true; + }); + + // Sort and calculate + sortedData = [...filteredData].sort((a, b) => b.count - a.count); + totalEndpoints = filteredData.length; + totalVisits = filteredData.reduce((sum, item) => sum + item.count, 0); + + // Update stats + document.getElementById('totalEndpoints').textContent = totalEndpoints.toLocaleString(); + document.getElementById('totalVisits').textContent = totalVisits.toLocaleString(); + + // Update the chart with current limit + const currentLimit = document.getElementById('currentlyShowing').textContent; + const limit = (currentLimit === endpointStatsTranslations.all) + ? filteredData.length + : (currentLimit === endpointStatsTranslations.top20 ? 20 : 10); + updateChart(limit); +} + +// Function to fetch data from the API +async function fetchEndpointData() { + try { + // Show loading state + const chartContainer = document.querySelector('.chart-container'); + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'loading'; + loadingDiv.innerHTML = ` +
+ ${endpointStatsTranslations.loading} +
`; + chartContainer.appendChild(loadingDiv); + + // Also add animation to refresh button + const refreshBtn = document.getElementById('refreshBtn'); + refreshBtn.classList.add('refreshing'); + refreshBtn.disabled = true; + + const response = await fetch('/api/v1/info/load/all'); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const data = await response.json(); + allEndpointData = data; + + // Apply filters + filterData(); + + // Remove loading state + chartContainer.removeChild(loadingDiv); + refreshBtn.classList.remove('refreshing'); + refreshBtn.disabled = false; + + } catch (error) { + console.error('Error fetching endpoint data:', error); + // Show error message to user + showError(endpointStatsTranslations.failedToLoad); + + // Reset refresh button + const refreshBtn = document.getElementById('refreshBtn'); + refreshBtn.classList.remove('refreshing'); + refreshBtn.disabled = false; + } +} + +// Function to format endpoint names +function formatEndpointName(endpoint) { + if (endpoint === '/') return endpointStatsTranslations.home; + if (endpoint === '/login') return endpointStatsTranslations.login; + return endpoint.replace('/', '').replace(/-/g, ' '); +} + +// Function to update the table +function updateTable(data) { + const tableBody = document.getElementById('endpointTableBody'); + tableBody.innerHTML = ''; + + data.forEach((item, index) => { + const percentage = ((item.count / totalVisits) * 100).toFixed(2); + const row = document.createElement('tr'); + + // Format endpoint for better readability + let displayEndpoint = item.endpoint; + if (displayEndpoint.length > 40) { + displayEndpoint = displayEndpoint.substring(0, 37) + '...'; + } + + row.innerHTML = ` + ${index + 1} + ${displayEndpoint} + ${item.count.toLocaleString()} + ${percentage}% + `; + + tableBody.appendChild(row); + }); +} + +// Function to update the chart +function updateChart(dataLimit) { + const chartData = sortedData.slice(0, dataLimit); + + // Calculate displayed statistics + const displayedVisits = chartData.reduce((sum, item) => sum + item.count, 0); + const displayedPercentage = totalVisits > 0 + ? ((displayedVisits / totalVisits) * 100).toFixed(2) + : '0'; + + document.getElementById('displayedVisits').textContent = displayedVisits.toLocaleString(); + document.getElementById('displayedPercentage').textContent = displayedPercentage; + + // If the limit equals the total filtered items, show "All"; otherwise "Top X" + document.getElementById('currentlyShowing').textContent = + (dataLimit === filteredData.length) + ? endpointStatsTranslations.all + : endpointStatsTranslations.top + dataLimit; + + // Update the table with new data + updateTable(chartData); + + // Prepare labels and datasets + const labels = chartData.map(item => formatEndpointName(item.endpoint)); + const data = chartData.map(item => item.count); + + // Get theme-specific colors + const colors = getChartColors(); + + // Destroy previous chart if it exists + if (myChart) { + myChart.destroy(); + } + + // Create chart context + const ctx = document.getElementById('endpointChart').getContext('2d'); + + // Create new chart with theme-appropriate colors + myChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [{ + label: endpointStatsTranslations.numberOfVisits, + data: data, + backgroundColor: colors.primaryColor.replace('rgb', 'rgba').replace(')', ', 0.6)'), + borderColor: colors.primaryColor, + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + indexAxis: dataLimit > 20 ? 'x' : 'y', + plugins: { + legend: { + display: true, + position: 'top', + labels: { + color: colors.primaryColor, + font: { + weight: 'bold' + } + } + }, + tooltip: { + backgroundColor: colors.tooltipBgColor, + titleColor: colors.tooltipTextColor, + bodyColor: colors.tooltipTextColor, + borderColor: colors.tooltipBgColor, + borderWidth: 1, + padding: 12, + cornerRadius: 8, + titleFont: { + size: 14, + weight: 'bold' + }, + bodyFont: { + size: 13 + }, + callbacks: { + label: (context) => { + const value = context.raw; + const percentage = totalVisits > 0 + ? ((value / totalVisits) * 100).toFixed(2) + : '0'; + // Insert your i18n text in the final string: + // e.g. "Visits: 12 (34% of total)" + // If your translation includes placeholders, you'd parse them here: + return endpointStatsTranslations.visitsTooltip + .replace('{0}', value.toLocaleString()) + .replace('{1}', percentage); + } + } + } + }, + scales: { + x: { + border: { + color: colors.gridColor + }, + ticks: { + color: colors.gridColor, + font: { + size: 12 + }, + callback: function(value, index, values) { + let label = this.getLabelForValue(value); + return label.length > 15 ? label.substr(0, 15) + '...' : label; + } + }, + grid: { + color: `${colors.gridColor}` + } + }, + y: { + border: { + color: colors.gridColor + }, + min: 0, + ticks: { + color: colors.gridColor, + font: { + size: 12 + }, + precision: 0 + }, + grid: { + color: `${colors.gridColor}` + } + } + } + } + }); +} + +// Initialize with fetch and top 10 +document.addEventListener('DOMContentLoaded', function() { + // Set up theme change listener + setupThemeChangeListener(); + + // Initial data fetch + fetchEndpointData(); + + // Set up button event listeners + document.getElementById('top10Btn').addEventListener('click', function() { + updateChart(10); + setActiveButton(this); + }); + + document.getElementById('top20Btn').addEventListener('click', function() { + updateChart(20); + setActiveButton(this); + }); + + document.getElementById('allBtn').addEventListener('click', function() { + updateChart(filteredData.length); + setActiveButton(this); + }); + + document.getElementById('refreshBtn').addEventListener('click', function() { + fetchEndpointData(); + }); + + // Set up filter checkbox listeners + document.getElementById('hideHomeCheckbox').addEventListener('change', filterData); + document.getElementById('hideLoginCheckbox').addEventListener('change', filterData); +}); + +function setActiveButton(activeButton) { + // Remove active class from all buttons + document.querySelectorAll('.chart-controls button').forEach(button => { + button.classList.remove('active'); + }); + // Add active class to clicked button + activeButton.classList.add('active'); +} + +// Function to handle errors in a user-friendly way +function showError(message) { + const chartContainer = document.querySelector('.chart-container'); + const errorDiv = document.createElement('div'); + errorDiv.className = 'alert alert-danger'; + errorDiv.innerHTML = ` + error + ${message} + + `; + + chartContainer.innerHTML = ''; + chartContainer.appendChild(errorDiv); + + // Add retry button functionality + document.getElementById('errorRetryBtn').addEventListener('click', fetchEndpointData); +} diff --git a/src/main/resources/static/pdfjs-legacy/pdf.mjs b/src/main/resources/static/pdfjs-legacy/pdf.mjs index da8e4014..048c120d 100644 --- a/src/main/resources/static/pdfjs-legacy/pdf.mjs +++ b/src/main/resources/static/pdfjs-legacy/pdf.mjs @@ -4630,7 +4630,7 @@ if (DESCRIPTORS && !('size' in URLSearchParamsPrototype)) { /************************************************************************/ /******/ // The module cache /******/ var __webpack_module_cache__ = {}; -/******/ +/******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ // Check if module is in cache @@ -4644,14 +4644,14 @@ if (DESCRIPTORS && !('size' in URLSearchParamsPrototype)) { /******/ // no module.loaded needed /******/ exports: {} /******/ }; -/******/ +/******/ /******/ // Execute the module function /******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ +/******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } -/******/ +/******/ /************************************************************************/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { @@ -4664,12 +4664,12 @@ if (DESCRIPTORS && !('size' in URLSearchParamsPrototype)) { /******/ } /******/ }; /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); -/******/ +/******/ /************************************************************************/ var __webpack_exports__ = globalThis.pdfjsLib = {}; // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. @@ -6385,8 +6385,8 @@ function setLayerDimensions(div, viewport, mustFlip = false, mustRotate = true) const useRound = util_FeatureTest.isCSSRoundSupported; const w = `var(--scale-factor) * ${pageWidth}px`, h = `var(--scale-factor) * ${pageHeight}px`; - const widthStr = useRound ? `round(up, ${w}, 1px)` : `calc(${w})`, - heightStr = useRound ? `round(up, ${h}, 1px)` : `calc(${h})`; + const widthStr = useRound ? `round(${w}, 1px)` : `calc(${w})`, + heightStr = useRound ? `round(${h}, 1px)` : `calc(${h})`; if (!mustFlip || viewport.rotation % 180 === 0) { style.width = widthStr; style.height = heightStr; diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index ae7c72f9..662a4f72 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -349,7 +349,7 @@
diff --git a/src/main/resources/templates/addUsers.html b/src/main/resources/templates/addUsers.html deleted file mode 100644 index 8d86ca81..00000000 --- a/src/main/resources/templates/addUsers.html +++ /dev/null @@ -1,308 +0,0 @@ - - - - - - - - - -
-
- -

-
-
-
-
- manage_accounts - Admin User Control Settings -
- - -
- - person_add - Add New User - - - edit - Change User's Role - -
- Total Users: - Active Users: - Disabled Users: - - Total Sessions: - - - Total Sessions: / - -
-
-
-
- Default message if not found -
-
-
-
- Default message if not found -
-
-
- Default message if not found -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#UsernameRolesAuthenticatedLast RequestUser SessionsActions
- - - - edit - -
- - - -
-
-
-

- - -
-
-
-
- - - - - - - - - - - -
- - \ No newline at end of file diff --git a/src/main/resources/templates/adminSettings.html b/src/main/resources/templates/adminSettings.html new file mode 100644 index 00000000..7d54331b --- /dev/null +++ b/src/main/resources/templates/adminSettings.html @@ -0,0 +1,360 @@ + + + + + + + + + + + +
+
+ +

+
+
+
+
+ manage_accounts + Admin User Control Settings +
+ + +
+ + person_add + Add New User + + + edit + Change User's Role + + + analytics + Usage Statistics + +
+ Total Users: + Active Users: + + Disabled Users: + + + Total + Sessions: + + + Total + Sessions: / + +
+
+ +
+
+ Default message if not found +
+
+
+
+ Default message if not found +
+
+
+ Default message if not found +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#UsernameRoles + AuthenticatedLast RequestUser SessionsActions
+ + + +
+ +
+ edit +
+
+ + + +
+
+
+

+ + +
+
+
+
+ + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/security/sanitize-pdf.html b/src/main/resources/templates/security/sanitize-pdf.html index c24b494b..575cca6d 100644 --- a/src/main/resources/templates/security/sanitize-pdf.html +++ b/src/main/resources/templates/security/sanitize-pdf.html @@ -29,8 +29,12 @@
- - + + +
+
+ +
diff --git a/src/main/resources/templates/usage.html b/src/main/resources/templates/usage.html new file mode 100644 index 00000000..4019272c --- /dev/null +++ b/src/main/resources/templates/usage.html @@ -0,0 +1,110 @@ + + + + + + + + + + +
+
+ +

+
+
+
+
+ analytics + Endpoint Statistics +
+ + +
+
+ + + + +
+ +
+ + +
+ +
+ Total Endpoints: 0 + Total Visits: 0 + Showing: Top 10 + Selected Visits: 0 (0%) +
+
+ + +
+
+ +
+
+ + +
+ + + + + + + + + + + + +
#EndpointVisitsPercentage
+
+
+
+
+
+ + + + + +
+ + \ No newline at end of file diff --git a/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPdfTest.java b/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPdfTest.java index 952d765f..a67e84f6 100644 --- a/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPdfTest.java +++ b/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPdfTest.java @@ -9,6 +9,7 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import stirling.software.SPDF.config.RuntimePathConfig; +import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.api.converters.UrlToPdfRequest; import stirling.software.SPDF.service.CustomPDFDocumentFactory; @@ -18,12 +19,18 @@ public class ConvertWebsiteToPdfTest { @Mock private RuntimePathConfig runtimePathConfig; + private ApplicationProperties applicationProperties; + private ConvertWebsiteToPDF convertWebsiteToPDF; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - convertWebsiteToPDF = new ConvertWebsiteToPDF(mockPdfDocumentFactory, runtimePathConfig); + applicationProperties = new ApplicationProperties(); + applicationProperties.getSystem().setEnableUrlToPDF(true); + convertWebsiteToPDF = + new ConvertWebsiteToPDF( + mockPdfDocumentFactory, runtimePathConfig, applicationProperties); } @Test diff --git a/testing/webpage_urls_full.txt b/testing/webpage_urls_full.txt index 557fdfb1..86b90872 100644 --- a/testing/webpage_urls_full.txt +++ b/testing/webpage_urls_full.txt @@ -60,7 +60,6 @@ /split-pdf-by-sections /split-pdfs /stamp -/url-to-pdf /validate-signature /view-pdf /swagger-ui/index.html \ No newline at end of file From 1a1bb8b7018549eef9dcdf63464e216603de97e6 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 16:42:34 +0100 Subject: [PATCH 24/61] Update EndpointInterceptor.java --- .../java/stirling/software/SPDF/config/EndpointInterceptor.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index a02ef874..69222328 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -11,8 +11,6 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.interfaces.SessionsInterface; -import lombok.extern.slf4j.Slf4j; - @Component @Slf4j public class EndpointInterceptor implements HandlerInterceptor { From dcac4cbbb18880577bcbbb617b551b64f23c7480 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 16:42:39 +0100 Subject: [PATCH 25/61] Update messages_en_GB.properties --- src/main/resources/messages_en_GB.properties | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index a6be769f..006e4e7a 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -231,7 +231,33 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.userSessions=User sessions +adminUserSettings.totalSessions=Total Sessions: +adminUserSettings.usage=View Usage +endpointStatistics.title=Endpoint Statistics +endpointStatistics.header=Endpoint Statistics +endpointStatistics.top10=Top 10 +endpointStatistics.top20=Top 20 +endpointStatistics.all=All +endpointStatistics.refresh=Refresh +endpointStatistics.includeHomepage=Include Homepage ('/') +endpointStatistics.includeLoginPage=Include Login Page ('/login') +endpointStatistics.totalEndpoints=Total Endpoints +endpointStatistics.totalVisits=Total Visits +endpointStatistics.showing=Showing +endpointStatistics.selectedVisits=Selected Visits +endpointStatistics.endpoint=Endpoint +endpointStatistics.visits=Visits +endpointStatistics.percentage=Percentage +endpointStatistics.loading=Loading... +endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. +endpointStatistics.home=Home +endpointStatistics.login=Login +endpointStatistics.top=Top +endpointStatistics.numberOfVisits=Number of Visits +endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) +endpointStatistics.retry=Retry database.title=Database Import/Export database.header=Database Import/Export From 7b709775a6a5e01de12255e8151517fa42d4eeb6 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 16:42:51 +0100 Subject: [PATCH 26/61] Update RequestUriUtils.java --- src/main/java/stirling/software/SPDF/utils/RequestUriUtils.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/stirling/software/SPDF/utils/RequestUriUtils.java b/src/main/java/stirling/software/SPDF/utils/RequestUriUtils.java index 7033fa7a..504fe64e 100644 --- a/src/main/java/stirling/software/SPDF/utils/RequestUriUtils.java +++ b/src/main/java/stirling/software/SPDF/utils/RequestUriUtils.java @@ -39,6 +39,7 @@ public class RequestUriUtils { || requestURI.endsWith(".css") || requestURI.endsWith(".map") || requestURI.endsWith(".svg") + || requestURI.endsWith("popularity.txt") || requestURI.endsWith(".js") || requestURI.contains("swagger") || requestURI.startsWith("/api/v1/info") From 5e5dc2e0c322712ac3f1719001f66cdfb9abcce1 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 26 Mar 2025 16:59:05 +0100 Subject: [PATCH 27/61] Update messages_de_DE.properties --- src/main/resources/messages_de_DE.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/resources/messages_de_DE.properties b/src/main/resources/messages_de_DE.properties index b4433f25..27c0341f 100644 --- a/src/main/resources/messages_de_DE.properties +++ b/src/main/resources/messages_de_DE.properties @@ -231,6 +231,8 @@ adminUserSettings.activeUsers=Aktive Benutzer: adminUserSettings.disabledUsers=Deaktivierte Benutzer: adminUserSettings.totalUsers=Gesamtzahl der Benutzer: adminUserSettings.lastRequest=Letzte Anfrage +adminUserSettings.userSessions=Benutzersitzungen +adminUserSettings.totalSessions=Gesamtzahl der Sitzungen: adminUserSettings.usage=View Usage endpointStatistics.title=Endpoint Statistics From 389f1f31e9ff8fec9465d68ca657411dffc59c8c Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 12:17:33 +0100 Subject: [PATCH 28/61] Create SessionStatusController.java --- .../session/SessionStatusController.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java new file mode 100644 index 00000000..3c8bde31 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java @@ -0,0 +1,101 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.security.UserUtils; +import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; + +@RestController +@Slf4j +public class SessionStatusController { + + @Autowired private SessionPersistentRegistry sessionPersistentRegistry; + + @GetMapping("/session/status") + public ResponseEntity getSessionStatus(HttpServletRequest request) { + HttpSession session = request.getSession(false); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.isAuthenticated()) { + Object principalTest = authentication.getPrincipal(); + String username = UserUtils.getUsernameFromPrincipal(principalTest); + + List allSessions = + sessionPersistentRegistry.getAllSessions(username, false); + + boolean isActivSession = + sessionPersistentRegistry.getAllSessions().stream() + .filter( + sessionEntity -> + session.getId().equals(sessionEntity.getSessionId())) + .anyMatch(sessionEntity -> !sessionEntity.isExpired()); + + int userSessions = allSessions.size(); + int maxUserSessions = sessionPersistentRegistry.getMaxUserSessions(); + + if (userSessions >= maxUserSessions && !isActivSession) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Session ungültig oder abgelaufen"); + } else if (session.getId() != null && isActivSession) { + return ResponseEntity.ok("Session gültig: " + session.getId()); + } else { + return ResponseEntity.ok( + "User: " + username + " has " + userSessions + " sessions"); + } + } else { + log.info("Session ungültig oder abgelaufen"); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(sessionPersistentRegistry.getAllSessionsNotExpired().size() + ""); + } + + @GetMapping("/session/expire") + public ResponseEntity expireSession(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session != null) { + sessionPersistentRegistry.expireSession(session.getId()); + return ResponseEntity.ok("Session invalidated"); + } else { + return ResponseEntity.ok("No session to invalidate"); + } + } + + @GetMapping("/session/expire/all") + public ResponseEntity expireAllSessions() { + sessionPersistentRegistry.expireAllSessions(); + return ResponseEntity.ok("All sessions invalidated"); + } + + @GetMapping("/session/expire/{username}") + public ResponseEntity expireAllSessionsByUsername(@PathVariable String username) { + SecurityContext cxt = SecurityContextHolder.getContext(); + Authentication auth = cxt.getAuthentication(); + if (auth != null && auth.isAuthenticated()) { + Object principal = auth.getPrincipal(); + String principalName = UserUtils.getUsernameFromPrincipal(principal); + if (principalName.equals(username)) { + sessionPersistentRegistry.expireAllSessionsByUsername(username); + return ResponseEntity.ok("All sessions invalidated for user: " + username); + } else { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); + } + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); + } +} From 41619d47c56a857cea9aebc11128b77baf83851e Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:07:33 +0100 Subject: [PATCH 29/61] Update AnonymusSessionInfo.java --- .../anonymus/session/AnonymusSessionInfo.java | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java index 47c8a353..5543deac 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java @@ -4,9 +4,21 @@ import java.util.Date; import jakarta.servlet.http.HttpSession; -public class AnonymusSessionInfo { +import lombok.AccessLevel; +import lombok.Setter; +import lombok.ToString; + +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + +@Setter +@ToString(exclude = "session") +public class AnonymusSessionInfo implements SessionsModelInterface { + private static final String principalName = "anonymousUser"; private HttpSession session; + + @Setter(AccessLevel.NONE) private final Date createdAt; + private Date lastRequest; private Boolean expired; @@ -14,12 +26,8 @@ public class AnonymusSessionInfo { HttpSession session, Date createdAt, Date lastRequest, Boolean expired) { this.session = session; this.createdAt = createdAt; - this.expired = expired; this.lastRequest = lastRequest; - } - - public void setSession(HttpSession session) { - this.session = session; + this.expired = expired; } public HttpSession getSession() { @@ -30,19 +38,23 @@ public class AnonymusSessionInfo { return createdAt; } - public void setExpired(Boolean expired) { - this.expired = expired; - } - - public Boolean isExpired() { - return expired; - } - - public void setLastRequest(Date lastRequest) { - this.lastRequest = lastRequest; - } - + @Override public Date getLastRequest() { return lastRequest; } + + @Override + public boolean isExpired() { + return expired; + } + + @Override + public String getSessionId() { + return session.getId(); + } + + @Override + public String getPrincipalName() { + return principalName; + } } From 813897175c8ae55ce8100970fbd4901cfd906877 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:10:18 +0100 Subject: [PATCH 30/61] Update SessionEntity.java --- .../software/SPDF/model/SessionEntity.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/model/SessionEntity.java b/src/main/java/stirling/software/SPDF/model/SessionEntity.java index bba7b33d..f410f662 100644 --- a/src/main/java/stirling/software/SPDF/model/SessionEntity.java +++ b/src/main/java/stirling/software/SPDF/model/SessionEntity.java @@ -9,15 +9,34 @@ import jakarta.persistence.Table; import lombok.Data; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + @Entity @Data @Table(name = "sessions") -public class SessionEntity implements Serializable { +public class SessionEntity implements Serializable, SessionsModelInterface { @Id private String sessionId; - private String principalName; - private Date lastRequest; - private boolean expired; + + @Override + public String getSessionId() { + return sessionId; + } + + @Override + public String getPrincipalName() { + return principalName; + } + + @Override + public Date getLastRequest() { + return lastRequest; + } + + @Override + public boolean isExpired() { + return expired; + } } From 287a815793393431e67782b039dfe9fe2cb31f13 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:13:05 +0100 Subject: [PATCH 31/61] Update UserAuthenticationFilter.java --- .../security/UserAuthenticationFilter.java | 72 ++----------------- 1 file changed, 4 insertions(+), 68 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index 4d41a562..6ddd893a 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -1,7 +1,6 @@ package stirling.software.SPDF.config.security; import java.io.IOException; -import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -23,7 +22,6 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; @@ -34,7 +32,6 @@ import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.SAML2; -import stirling.software.SPDF.model.SessionEntity; import stirling.software.SPDF.model.User; @Slf4j @@ -63,67 +60,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { throws ServletException, IOException { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() && loginEnabledValue) { - Object principalTest = authentication.getPrincipal(); - String username = UserUtils.getUsernameFromPrincipal(principalTest); - - List allSessions = - sessionPersistentRegistry.getAllSessions(username, false); - - int userSessions = allSessions.size(); - int maxUserSessions = sessionPersistentRegistry.getMaxUserSessions(); - - HttpSession session = request.getSession(false); - if (session == null) { - filterChain.doFilter(request, response); - return; - } - String sessionId = session.getId(); - - if (userSessions > maxUserSessions) { - // Sortiere nach letzter Aktivität – älteste zuerst - List sortedSessions = - allSessions.stream() - .sorted(Comparator.comparing(SessionInformation::getLastRequest)) - .collect(Collectors.toList()); - int sessionsToExpire = userSessions - maxUserSessions; - log.info("Expire {} old sessions", sessionsToExpire); - for (int i = 0; i < sessionsToExpire; i++) { - SessionInformation oldSession = sortedSessions.get(i); - if (!sessionId.equals(oldSession.getSessionId())) { - sessionPersistentRegistry.expireSession(oldSession.getSessionId()); - oldSession.expireNow(); - log.info( - "Expired old session: {} (last request: {})", - oldSession.getSessionId(), - oldSession.getLastRequest()); - } - } - } - for (SessionInformation sessionInformation : allSessions) { - if (sessionId.equals(sessionInformation.getSessionId())) { - sessionPersistentRegistry.refreshLastRequest(sessionId); - } - } - allSessions = sessionPersistentRegistry.getAllSessions(username, false); - - log.info( - "username: {} || before Sessions: {} | after Sessions: {}", - username, - userSessions, - allSessions.size()); - - SessionEntity sessionEntity = sessionPersistentRegistry.getSessionEntity(sessionId); - - if (allSessions.isEmpty() || sessionEntity.isExpired()) { - log.info("No sessions found for user: {}", username); - sessionPersistentRegistry.expireSession(sessionId); - authentication.setAuthenticated(false); - SecurityContextHolder.clearContext(); - response.sendRedirect(request.getContextPath() + "/login?error=expiredSession"); - return; - } - } if (!loginEnabledValue) { // If login is not enabled, just pass all requests without authentication filterChain.doFilter(request, response); @@ -175,10 +111,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { response.getWriter() .write( "Authentication required. Please provide a X-API-KEY in request" - + " header.\n" - + "This is found in Settings -> Account Settings -> API Key\n" - + "Alternatively you can disable authentication if this is" - + " unexpected"); + + " header.\n" + + "This is found in Settings -> Account Settings -> API Key\n" + + "Alternatively you can disable authentication if this is" + + " unexpected"); return; } } From 355c09e509e731c02fbe26b2289f4906b570ab96 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:13:13 +0100 Subject: [PATCH 32/61] Update SessionStatusController.java --- .../config/security/session/SessionStatusController.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java index 3c8bde31..26109e99 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java @@ -15,14 +15,11 @@ import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; - import lombok.extern.slf4j.Slf4j; - import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; @RestController -@Slf4j public class SessionStatusController { @Autowired private SessionPersistentRegistry sessionPersistentRegistry; @@ -59,10 +56,8 @@ public class SessionStatusController { "User: " + username + " has " + userSessions + " sessions"); } } else { - log.info("Session ungültig oder abgelaufen"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Session ungültig oder abgelaufen"); } - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(sessionPersistentRegistry.getAllSessionsNotExpired().size() + ""); } @GetMapping("/session/expire") From d1ec9ccb84ba2b9ae82cdd6f3ec0f40a33d791da Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:13:28 +0100 Subject: [PATCH 33/61] Update SessionRepository.java --- .../SPDF/config/security/session/SessionRepository.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java index 933c2012..0c4109a7 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java @@ -21,6 +21,10 @@ public interface SessionRepository extends JpaRepository SessionEntity findBySessionId(String sessionId); + List findBySessionIdAndExpired(String sessionId, boolean expired); + + void deleteByPrincipalName(String principalName); + @Modifying @Transactional @Query( From 8876d31bf758fec9e5bb35d102c2736c8e09f3e0 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:13:37 +0100 Subject: [PATCH 34/61] Update SessionPersistentRegistry.java --- .../session/SessionPersistentRegistry.java | 86 ++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java index 8db40423..72ea5364 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java @@ -3,9 +3,11 @@ package stirling.software.SPDF.config.security.session; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -84,6 +86,7 @@ public class SessionPersistentRegistry implements SessionRegistry { sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date sessionEntity.setExpired(false); sessionRepository.save(sessionEntity); + sessionRepository.flush(); } } @@ -91,6 +94,13 @@ public class SessionPersistentRegistry implements SessionRegistry { @Transactional public void removeSessionInformation(String sessionId) { sessionRepository.deleteById(sessionId); + sessionRepository.flush(); + } + + @Transactional + public void removeSessionInformationByPrincipalName(String principalName) { + sessionRepository.deleteByPrincipalName(principalName); + sessionRepository.flush(); } @Override @@ -105,6 +115,39 @@ public class SessionPersistentRegistry implements SessionRegistry { } } + @Transactional + public void expireOldestSessionForPrincipal(String principalName) { + // Alle Sessions des principalName abrufen + List sessionsForPrincipal = + sessionRepository.findByPrincipalName(principalName); + + // Nur die nicht abgelaufenen Sessions filtern + List nonExpiredSessions = + sessionsForPrincipal.stream() + .filter(session -> !session.isExpired()) + .collect(Collectors.toList()); + + if (nonExpiredSessions.isEmpty()) { + log.info("Keine nicht abgelaufenen Sessions für principal {} gefunden", principalName); + return; + } + + // Die Session mit dem ältesten lastRequest ermitteln + Optional oldestSessionOpt = + nonExpiredSessions.stream() + .min(Comparator.comparing(SessionEntity::getLastRequest)); + + if (oldestSessionOpt.isPresent()) { + SessionEntity oldestSession = oldestSessionOpt.get(); + expireSession(oldestSession.getSessionId()); + removeSessionInformation(oldestSession.getSessionId()); + log.info( + "Die älteste Session {} für principal {} wurde als expired markiert", + oldestSession.getSessionId(), + principalName); + } + } + @Override public SessionInformation getSessionInformation(String sessionId) { Optional sessionEntityOpt = sessionRepository.findById(sessionId); @@ -118,6 +161,11 @@ public class SessionPersistentRegistry implements SessionRegistry { return null; } + // Retrieve all non-expired sessions + public List getAllNonExpiredSessionsBySessionId(String sessionId) { + return sessionRepository.findBySessionIdAndExpired(sessionId, false); + } + // Retrieve all non-expired sessions public List getAllSessionsNotExpired() { return sessionRepository.findByExpired(false); @@ -138,15 +186,47 @@ public class SessionPersistentRegistry implements SessionRegistry { } } - // Mark all sessions as expired for a given principal name - public void expireAllSessionsByPrincipalName(String principalName) { - List sessionEntities = sessionRepository.findByPrincipalName(principalName); + // Mark all sessions as expired + public void expireAllSessions() { + List sessionEntities = sessionRepository.findAll(); for (SessionEntity sessionEntity : sessionEntities) { sessionEntity.setExpired(true); // Set expired to true sessionRepository.save(sessionEntity); } } + // Mark all sessions as expired by username + public void expireAllSessionsByUsername(String username) { + List sessionEntities = sessionRepository.findByPrincipalName(username); + for (SessionEntity sessionEntity : sessionEntities) { + sessionEntity.setExpired(true); // Set expired to true + sessionRepository.save(sessionEntity); + } + } + + // Mark all sessions as expired for a given principal name + public void expireAllSessionsByPrincipalName(String principalName) { + List sessionEntities = sessionRepository.findByPrincipalName(principalName); + log.info("Session entities: {}", sessionEntities.size()); + for (SessionEntity sessionEntity : sessionEntities) { + log.info( + "Session expired: {} {} {}", + sessionEntity.getPrincipalName(), + sessionEntity.isExpired(), + sessionEntity.getSessionId()); + sessionEntity.setExpired(true); // Set expired to true + removeSessionInformation(sessionEntity.getSessionId()); + // sessionRepository.flush(); + } + sessionEntities = sessionRepository.findByPrincipalName(principalName); + log.info("Session entities: {}", sessionEntities.size()); + for (SessionEntity sessionEntity : sessionEntities) { + if (sessionEntity.getPrincipalName().equals(principalName)) { + log.info("Session expired: {}", sessionEntity.getSessionId()); + } + } + } + // Get the maximum inactive interval for sessions public int getMaxInactiveInterval() { return (int) defaultMaxInactiveInterval.getSeconds(); From b080704bcd5089b7486ecefd2a23bcea9858b128 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:13:46 +0100 Subject: [PATCH 35/61] Update CustomHttpSessionListener.java --- .../session/CustomHttpSessionListener.java | 163 ++++++++++++++++-- 1 file changed, 153 insertions(+), 10 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java index 4fb79d38..05291591 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java @@ -1,57 +1,200 @@ package stirling.software.SPDF.config.security.session; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Component; +import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionListener; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.SessionsInterface; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; import stirling.software.SPDF.config.security.UserUtils; +import stirling.software.SPDF.model.SessionEntity; @Component @Slf4j -public class CustomHttpSessionListener implements HttpSessionListener { +public class CustomHttpSessionListener implements HttpSessionListener, SessionsInterface { private final SessionPersistentRegistry sessionPersistentRegistry; + @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m + private Duration defaultMaxInactiveInterval; + public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) { super(); this.sessionPersistentRegistry = sessionPersistentRegistry; } + @Override + public Collection getAllNonExpiredSessions() { + return sessionPersistentRegistry.getAllSessionsNotExpired().stream() + .map(session -> (SessionsModelInterface) session) + .toList(); + } + + @Override + public Collection getAllNonExpiredSessionsBySessionId( + String sessionId) { + return sessionPersistentRegistry.getAllNonExpiredSessionsBySessionId(sessionId).stream() + .map(session -> (SessionsModelInterface) session) + .toList(); + } + + @Override + public Collection getAllSessions() { + return new ArrayList<>(sessionPersistentRegistry.getAllSessions()); + } + + @Override + public boolean isSessionValid(String sessionId) { + List allSessions = sessionPersistentRegistry.getAllSessions(); + // gib zurück ob ist expired + return allSessions.stream() + .anyMatch( + session -> + session.getSessionId().equals(sessionId) && !session.isExpired()); + } + + @Override + public boolean isOldestNonExpiredSession(String sessionId) { + log.info("isOldestNonExpiredSession for sessionId: {}", sessionId); + List nonExpiredSessions = + sessionPersistentRegistry.getAllSessionsNotExpired(); + return nonExpiredSessions.stream() + .min(Comparator.comparing(SessionEntity::getLastRequest)) + .map(oldest -> oldest.getSessionId().equals(sessionId)) + .orElse(false); + } + + @Override + public void updateSessionLastRequest(String sessionId) { + sessionPersistentRegistry.refreshLastRequest(sessionId); + } + @Override public void sessionCreated(HttpSessionEvent se) { + HttpSession session = se.getSession(); + if (session == null) { + return; + } SecurityContext securityContext = SecurityContextHolder.getContext(); if (securityContext == null) { - log.debug("Security context is null"); return; } Authentication authentication = securityContext.getAuthentication(); if (authentication == null) { - log.info("Authentication is null"); return; } Object principal = authentication.getPrincipal(); if (principal == null) { - log.info("Principal is null"); return; } String principalName = UserUtils.getUsernameFromPrincipal(principal); - if (principalName == null || "anonymousUser".equals(principalName)) { - log.info("Principal is null or anonymousUser"); + if (principalName == null) { return; } - log.info("Session created: {}", principalName); - sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName); + session.setAttribute("principalName", principalName); + if ("anonymousUser".equals(principalName)) { + log.info("Principal is anonymousUser"); + } + int allNonExpiredSessions = getAllNonExpiredSessions().size(); + if (allNonExpiredSessions >= getMaxUserSessions()) { + log.info("Session {} Expired=TRUE", session.getId()); + sessionPersistentRegistry.expireSession(session.getId()); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); + // if (allNonExpiredSessions > getMaxUserSessions()) { + // enforceMaxSessionsForPrincipal(principalName); + // } + } else { + log.info("Session created: {}", principalName); + sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName); + } + } + + private void enforceMaxSessionsForPrincipal(String principalName) { + // Alle aktiven Sessions des Benutzers über das gemeinsame Interface abrufen + List userSessions = + getAllSessions().stream() + .filter(s -> !s.isExpired() && principalName.equals(s.getPrincipalName())) + .sorted(Comparator.comparing(SessionsModelInterface::getLastRequest)) + .collect(Collectors.toList()); + + int maxAllowed = getMaxUserSessions(); + if (userSessions.size() > maxAllowed) { + int sessionsToRemove = userSessions.size() - maxAllowed; + log.info( + "User {} has {} active sessions, removing {} oldest session(s).", + principalName, + userSessions.size(), + sessionsToRemove); + for (int i = 0; i < sessionsToRemove; i++) { + SessionsModelInterface sessionModel = userSessions.get(i); + // Statt auf die HttpSession zuzugreifen, rufen wir die Registry-Methoden auf, + // die die Session anhand der Session-ID invalidieren und entfernen. + sessionPersistentRegistry.expireSession(sessionModel.getSessionId()); + sessionPersistentRegistry.removeSessionInformation(sessionModel.getSessionId()); + log.info( + "Removed session {} for principal {}", + sessionModel.getSessionId(), + principalName); + } + } } @Override public void sessionDestroyed(HttpSessionEvent se) { - sessionPersistentRegistry.expireSession(se.getSession().getId()); - sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); + HttpSession session = se.getSession(); + if (session == null) { + return; + } + + SessionInformation sessionsInfo = + sessionPersistentRegistry.getSessionInformation(session.getId()); + if (sessionsInfo == null) { + return; + } + + Date lastRequest = sessionsInfo.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant now = Instant.now(); + Instant expirationTime = + lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + sessionPersistentRegistry.expireSession(session.getId()); + session.invalidate(); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); + log.info("Session {} wurde Expired=TRUE", session.getId()); + } + } + + @Override + public void registerSession(HttpSession session) { + sessionCreated(new HttpSessionEvent(session)); + } + + @Override + public void removeSession(HttpSession session) { + sessionPersistentRegistry.expireSession(session.getId()); + session.invalidate(); + sessionPersistentRegistry.removeSessionInformation(session.getId()); + log.info("Session {} wurde Expired=TRUE", session.getId()); } } From 5011b5c8ade42e36c262e670d6461471755cb031 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:13:57 +0100 Subject: [PATCH 36/61] Create SessionsModelInterface.java --- .../config/interfaces/SessionsModelInterface.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java new file mode 100644 index 00000000..dfe84012 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java @@ -0,0 +1,14 @@ +package stirling.software.SPDF.config.interfaces; + +import java.util.Date; + +public interface SessionsModelInterface { + + String getSessionId(); + + String getPrincipalName(); + + Date getLastRequest(); + + boolean isExpired(); +} From 6529382d939bc2a162b93e67830084d998703339 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:14:06 +0100 Subject: [PATCH 37/61] Update SessionsInterface.java --- .../config/interfaces/SessionsInterface.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java index e368046b..60173fb6 100644 --- a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java +++ b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java @@ -2,19 +2,31 @@ package stirling.software.SPDF.config.interfaces; import java.util.Collection; -import stirling.software.SPDF.config.anonymus.session.AnonymusSessionInfo; +import jakarta.servlet.http.HttpSession; public interface SessionsInterface { - default boolean isSessionValid(String sessionId) { - return false; - } + boolean isSessionValid(String sessionId); boolean isOldestNonExpiredSession(String sessionId); void updateSessionLastRequest(String sessionId); - Collection getAllSessions(); + Collection getAllSessions(); - Collection getAllNonExpiredSessions(); + Collection getAllNonExpiredSessions(); + + Collection getAllNonExpiredSessionsBySessionId(String sessionId); + + void registerSession(HttpSession session); + + void removeSession(HttpSession session); + + default int getMaxUserSessions() { + return 3; + } + + default int getMaxApplicationSessions() { + return 10 * getMaxUserSessions(); + } } From cb725ccf8c116d47487245e8462ccd99faeaeec1 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:14:18 +0100 Subject: [PATCH 38/61] Update EndpointInterceptor.java --- .../SPDF/config/EndpointInterceptor.java | 73 +++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index 69222328..cad97354 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.config; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -17,11 +18,15 @@ public class EndpointInterceptor implements HandlerInterceptor { private final EndpointConfiguration endpointConfiguration; private final SessionsInterface sessionsInterface; + private boolean loginEnabled = true; public EndpointInterceptor( - EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) { + EndpointConfiguration endpointConfiguration, + SessionsInterface sessionsInterface, + @Qualifier("loginEnabled") boolean loginEnabled) { this.endpointConfiguration = endpointConfiguration; this.sessionsInterface = sessionsInterface; + this.loginEnabled = loginEnabled; } @Override @@ -46,23 +51,81 @@ public class EndpointInterceptor implements HandlerInterceptor { || request.getRequestURI().contains("/error") || request.getRequestURI().contains("/session/status") || request.getRequestURI().contains("/session/expire") - || request.getRequestURI().contains("/session/expire-all") + || request.getRequestURI().contains("/session/expire/") || request.getRequestURI().endsWith(".js") || request.getRequestURI().endsWith(".png") || request.getRequestURI().endsWith(".webmanifest") || request.getRequestURI().contains("/files/")) { return true; } else { - String sessionId = session != null ? session.getId() : null; + if (session == null) { + session = request.getSession(true); + } + final HttpSession finalSession = session; + String sessionId = finalSession.getId(); - if (sessionId == null || !sessionsInterface.isSessionValid(sessionId)) { + // Den aktuellen Benutzer (principalName) aus der Session ermitteln. + // Es wird angenommen, dass das Attribut "principalName" in der Session gesetzt + // wurde. + final String currentPrincipal = + finalSession.getAttribute("principalName") != null + ? finalSession.getAttribute("principalName").toString() + : "unknown"; + + // Zähle alle nicht abgelaufenen Sessions des aktuellen Benutzers. + long userSessions = + sessionsInterface.getAllSessions().stream() + .filter( + s -> + !s.isExpired() + && currentPrincipal.equals( + s.getPrincipalName())) + .count(); + + // Zähle alle nicht abgelaufenen Sessions in der Anwendung. + long totalSessions = + sessionsInterface.getAllSessions().stream() + .filter(s -> !s.isExpired()) + .count(); + + log.info( + "Aktive Sessions für {}: {} (max: {}) | Gesamt: {} (max: {})", + currentPrincipal, + userSessions, + sessionsInterface.getMaxUserSessions(), + totalSessions, + sessionsInterface.getMaxApplicationSessions()); + + // Prüfe die Grenzen: + // Falls entweder die Benutzersessions oder die Anwendungssessions das Limit + // erreicht haben + // und die aktuelle Session noch NICHT registriert ist, dann wird ein Fehler + // zurückgegeben. + boolean isCurrentSessionRegistered = + sessionsInterface.getAllSessions().stream() + .filter(s -> !s.isExpired()) + .anyMatch(s -> s.getSessionId().equals(sessionId)); + + if ((userSessions >= sessionsInterface.getMaxUserSessions() + || totalSessions >= sessionsInterface.getMaxApplicationSessions()) + && !isCurrentSessionRegistered) { response.sendError( HttpServletResponse.SC_UNAUTHORIZED, - "Max sessions reached for this user. To continue on this device, please close your session in another browser."); + "Max sessions reached for this user. To continue on this device, please" + + " close your session in another browser."); return false; + } + + // Wenn die Session noch nicht registriert ist, registriere sie; andernfalls update + // den Last-Request. + if (!isCurrentSessionRegistered) { + log.info("Register session: {}", sessionId); + sessionsInterface.registerSession(finalSession); } else { + log.info("Update session last request: {}", sessionId); sessionsInterface.updateSessionLastRequest(sessionId); } + return true; } } From 27db4d6de2e897786ec1167f35aca6c5b0958103 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:14:28 +0100 Subject: [PATCH 39/61] Update AnonymusSessionStatusController.java --- .../AnonymusSessionStatusController.java | 56 ++++++++----------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java index fad15380..79e632f1 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java @@ -1,29 +1,20 @@ package stirling.software.SPDF.config.anonymus.session; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; - import lombok.extern.slf4j.Slf4j; -import stirling.software.SPDF.config.interfaces.SessionsInterface; - @RestController -@Slf4j public class AnonymusSessionStatusController { @Autowired private AnonymusSessionRegistry sessionRegistry; - @Autowired private SessionsInterface sessionsInterface; - private static final int MAX_SESSIONS = 1; @GetMapping("/session/status") public ResponseEntity getSessionStatus(HttpServletRequest request) { @@ -32,39 +23,32 @@ public class AnonymusSessionStatusController { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No session found"); } - Collection allNonExpiredSessions = - new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); - if (allNonExpiredSessions.isEmpty()) { - allNonExpiredSessions.add( - new AnonymusSessionInfo(session, new Date(), new Date(), false)); - } + boolean isActivSesssion = + sessionRegistry.getAllSessions().stream() + .filter(s -> s.getSessionId().equals(session.getId())) + .anyMatch(s -> !s.isExpired()); - // wenn session expire ist dann UNAUTHORIZED - if (allNonExpiredSessions.stream() - .anyMatch(s -> s.getSession().getId().equals(session.getId()) && s.isExpired())) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Session expired"); - } + long sessionCount = + sessionRegistry.getAllSessions().stream().filter(s -> !s.isExpired()).count(); - // wenn nicht in der Liste dann UNAUTHORIZED - if (allNonExpiredSessions.stream() - .noneMatch(s -> s.getSession().getId().equals(session.getId()))) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No session found"); - } + long userSessions = sessionCount; + int maxUserSessions = sessionRegistry.getMaxUserSessions(); - if (allNonExpiredSessions.size() > MAX_SESSIONS - && sessionsInterface.isSessionValid(session.getId()) - && sessionsInterface.isOldestNonExpiredSession(session.getId())) { + if (userSessions >= maxUserSessions && !isActivSesssion) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body("Session ungültig oder abgelaufen"); + } else if (session.getId() != null && isActivSesssion) { + return ResponseEntity.ok("Session gültig: " + session.getId()); + } else { + return ResponseEntity.ok("User has " + userSessions + " sessions"); } - return ResponseEntity.ok("Session gültig: " + session.getId()); } @GetMapping("/session/expire") public ResponseEntity expireSession(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session != null) { - session.invalidate(); + sessionRegistry.expireSession(session.getId()); return ResponseEntity.ok("Session invalidated"); } else { return ResponseEntity.ok("No session to invalidate"); @@ -73,9 +57,13 @@ public class AnonymusSessionStatusController { @GetMapping("/session/expire/all") public ResponseEntity expireAllSessions() { - sessionRegistry - .getAllNonExpiredSessions() - .forEach(sessionInfo -> sessionInfo.getSession().invalidate()); + sessionRegistry.expireAllSessions(); return ResponseEntity.ok("All sessions invalidated"); } + + @GetMapping("/session/expire/{username}") + public ResponseEntity expireAllSessionsByUsername(@PathVariable String username) { + sessionRegistry.expireAllSessionsByUsername(username); + return ResponseEntity.ok("All sessions invalidated for user: " + username); + } } From c14cb03390b3507c666321c8ba37538a7377cf7d Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:14:33 +0100 Subject: [PATCH 40/61] Update AnonymusSessionService.java --- .../config/anonymus/session/AnonymusSessionService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java index c5b8d907..cae44879 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java @@ -3,18 +3,16 @@ package stirling.software.SPDF.config.anonymus.session; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; -@Component @Slf4j public class AnonymusSessionService { @@ -27,7 +25,9 @@ public class AnonymusSessionService { public void expireSessions() { Instant now = Instant.now(); List allNonExpiredSessions = - new ArrayList<>(sessionRegistry.getAllNonExpiredSessions()); + sessionRegistry.getAllNonExpiredSessions().stream() + .map(s -> (AnonymusSessionInfo) s) + .collect(Collectors.toList()); for (AnonymusSessionInfo sessionInformation : allNonExpiredSessions) { Date lastRequest = sessionInformation.getLastRequest(); int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); From 9ec728ef001303d0ea327cb0bd48b14a561ad5a0 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 13:14:41 +0100 Subject: [PATCH 41/61] Update AnonymusSessionRegistry.java --- .../session/AnonymusSessionRegistry.java | 117 +++++++++++++++--- 1 file changed, 102 insertions(+), 15 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java index df919951..a315619f 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java @@ -19,6 +19,7 @@ import jakarta.servlet.http.HttpSessionListener; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.interfaces.SessionsInterface; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; @Component @Slf4j @@ -27,10 +28,8 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m private Duration defaultMaxInactiveInterval; - private static final int MAX_SESSIONS = 1; - // Map zur Speicherung der Sessions inkl. Timestamp - private static final Map sessions = new ConcurrentHashMap<>(); + private static final Map sessions = new ConcurrentHashMap<>(); @Override public void sessionCreated(HttpSessionEvent event) { @@ -43,15 +42,14 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt return; } - // Speichern des anonymousUser-Flags - session.setAttribute("anonymousUser", true); + session.setAttribute("principalName", "anonymousUser"); + // Speichern des Erstellungszeitpunkts Date creationTime = new Date(); - session.setAttribute("creationTimestamp", creationTime); int allNonExpiredSessions = getAllNonExpiredSessions().size(); - if (allNonExpiredSessions >= MAX_SESSIONS) { + if (allNonExpiredSessions >= getMaxUserSessions()) { sessions.put( session.getId(), new AnonymusSessionInfo(session, creationTime, creationTime, true)); @@ -68,7 +66,7 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt if (session == null) { return; } - AnonymusSessionInfo sessionsInfo = sessions.get(session.getId()); + AnonymusSessionInfo sessionsInfo = (AnonymusSessionInfo) sessions.get(session.getId()); if (sessionsInfo == null) { return; } @@ -86,6 +84,56 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt } } + // Make a session as expired + public void expireSession(String sessionId) { + if (sessions.containsKey(sessionId)) { + AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId); + sessionInfo.setExpired(true); + try { + sessionInfo.getSession().invalidate(); + } catch (IllegalStateException e) { + log.info("Session {} ist bereits invalidiert", sessionInfo.getSession().getId()); + } + } + } + + // Make all sessions as expired + public void expireAllSessions() { + sessions.values() + .forEach( + sessionInfo -> { + AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo; + info.setExpired(true); + HttpSession session = info.getSession(); + try { + session.invalidate(); + } catch (IllegalStateException e) { + log.info("Session {} ist bereits invalidiert", session.getId()); + } + }); + } + + // Mark all sessions as expired by username + public void expireAllSessionsByUsername(String username) { + sessions.values().stream() + .filter( + sessionInfo -> { + AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo; + return info.getPrincipalName().equals(username); + }) + .forEach( + sessionInfo -> { + AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo; + info.setExpired(true); + HttpSession session = info.getSession(); + try { + session.invalidate(); + } catch (IllegalStateException e) { + log.info("Session {} ist bereits invalidiert", session.getId()); + } + }); + } + @Override public boolean isSessionValid(String sessionId) { boolean exists = sessions.containsKey(sessionId); @@ -95,28 +143,67 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt @Override public boolean isOldestNonExpiredSession(String sessionId) { - Collection nonExpiredSessions = getAllNonExpiredSessions(); + Collection nonExpiredSessions = getAllNonExpiredSessions(); return nonExpiredSessions.stream() - .min(Comparator.comparing(AnonymusSessionInfo::getLastRequest)) - .map(oldest -> oldest.getSession().getId().equals(sessionId)) + .min(Comparator.comparing(SessionsModelInterface::getLastRequest)) + .map(oldest -> oldest.getSessionId().equals(sessionId)) .orElse(false); } @Override public void updateSessionLastRequest(String sessionId) { if (sessions.containsKey(sessionId)) { - AnonymusSessionInfo sessionInfo = sessions.get(sessionId); + AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId); sessionInfo.setLastRequest(new Date()); } } @Override - public Collection getAllSessions() { - return sessions.values(); + public Collection getAllSessions() { + return sessions.values().stream().toList(); } @Override - public Collection getAllNonExpiredSessions() { + public Collection getAllNonExpiredSessions() { return sessions.values().stream().filter(info -> !info.isExpired()).toList(); } + + public Collection getAllIsExpiredSessions() { + return sessions.values().stream().filter(SessionsModelInterface::isExpired).toList(); + } + + public void clear() { + sessions.clear(); + } + + @Override + public Collection getAllNonExpiredSessionsBySessionId( + String sessionId) { + return sessions.values().stream() + .filter(info -> !info.isExpired() && info.getSessionId().equals(sessionId)) + .toList(); + } + + @Override + public void registerSession(HttpSession session) { + if (!sessions.containsKey(session.getId())) { + AnonymusSessionInfo sessionInfo = + new AnonymusSessionInfo(session, new Date(), new Date(), false); + sessions.put(session.getId(), sessionInfo); + log.info("Session {} wurde registriert", session.getId()); + } + } + + @Override + public int getMaxApplicationSessions() { + return getMaxUserSessions(); + } + + @Override + public void removeSession(HttpSession session) { + AnonymusSessionInfo sessionsInfo = (AnonymusSessionInfo) sessions.get(session.getId()); + sessionsInfo.setExpired(true); + session.invalidate(); + sessions.remove(session.getId()); + } } From 0154e46c8ad5b44569c290688c4466c9c8f2dc2b Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 18:38:39 +0100 Subject: [PATCH 42/61] cookie for session --- .../SPDF/config/EndpointInterceptor.java | 4 +- testing/test.sh | 60 +++++++++---------- testing/test_webpages.sh | 15 +++-- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index cad97354..4ec70721 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -49,9 +49,7 @@ public class EndpointInterceptor implements HandlerInterceptor { || request.getRequestURI().contains("/images/") || request.getRequestURI().contains("/favicon") || request.getRequestURI().contains("/error") - || request.getRequestURI().contains("/session/status") - || request.getRequestURI().contains("/session/expire") - || request.getRequestURI().contains("/session/expire/") + || request.getRequestURI().contains("/session") || request.getRequestURI().endsWith(".js") || request.getRequestURI().endsWith(".png") || request.getRequestURI().endsWith(".webmanifest") diff --git a/testing/test.sh b/testing/test.sh index f1720318..a98d4bc0 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -43,7 +43,7 @@ check_health() { capture_file_list() { local container_name=$1 local output_file=$2 - + echo "Capturing file list from $container_name..." # Get all files in one command, output directly from Docker to avoid path issues # Skip proc, sys, dev, and the specified LibreOffice config directory @@ -60,12 +60,12 @@ capture_file_list() { -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ 2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file" - + # Check if the output file has content if [ ! -s "$output_file" ]; then echo "WARNING: Failed to capture file list or container returned empty list" echo "Trying alternative approach..." - + # Alternative simpler approach - just get paths as a fallback docker exec $container_name sh -c "find / -type f \ -not -path '*/proc/*' \ @@ -79,14 +79,14 @@ capture_file_list() { -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ 2>/dev/null | sort" > "$output_file" - + if [ ! -s "$output_file" ]; then echo "ERROR: All attempts to capture file list failed" # Create a dummy entry to prevent diff errors echo "NO_FILES_FOUND 0 0" > "$output_file" fi fi - + echo "File list captured to $output_file" } @@ -96,24 +96,24 @@ compare_file_lists() { local after_file=$2 local diff_file=$3 local container_name=$4 # Added container_name parameter - + echo "Comparing file lists..." - + # Check if files exist and have content if [ ! -s "$before_file" ] || [ ! -s "$after_file" ]; then echo "WARNING: One or both file lists are empty." - + if [ ! -s "$before_file" ]; then echo "Before file is empty: $before_file" fi - + if [ ! -s "$after_file" ]; then echo "After file is empty: $after_file" fi - + # Create empty diff file > "$diff_file" - + # Check if we at least have the after file to look for temp files if [ -s "$after_file" ]; then echo "Checking for temp files in the after snapshot..." @@ -128,23 +128,23 @@ compare_file_lists() { echo "No temporary files found in the after snapshot." fi fi - + return 0 fi - + # Both files exist and have content, proceed with diff diff "$before_file" "$after_file" > "$diff_file" - + if [ -s "$diff_file" ]; then echo "Detected changes in files:" cat "$diff_file" - + # Extract only added files (lines starting with ">") grep "^>" "$diff_file" > "${diff_file}.added" || true if [ -s "${diff_file}.added" ]; then echo "New files created during test:" cat "${diff_file}.added" | sed 's/^> //' - + # Check for tmp files grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true if [ -s "${diff_file}.tmp" ]; then @@ -155,7 +155,7 @@ compare_file_lists() { return 1 fi fi - + # Extract only removed files (lines starting with "<") grep "^<" "$diff_file" > "${diff_file}.removed" || true if [ -s "${diff_file}.removed" ]; then @@ -165,7 +165,7 @@ compare_file_lists() { else echo "No file changes detected during test." fi - + return 0 } @@ -212,8 +212,8 @@ main() { cd "$PROJECT_ROOT" - export DOCKER_CLI_EXPERIMENTAL=enabled - export COMPOSE_DOCKER_CLI_BUILD=0 + export DOCKER_CLI_EXPERIMENTAL=enabled + export COMPOSE_DOCKER_CLI_BUILD=0 export DOCKER_ENABLE_SECURITY=false # Run the gradlew build command and check if it fails if ! ./gradlew clean build; then @@ -282,27 +282,27 @@ main() { # Create directory for file snapshots if it doesn't exist SNAPSHOT_DIR="$PROJECT_ROOT/testing/file_snapshots" mkdir -p "$SNAPSHOT_DIR" - + # Capture file list before running behave tests BEFORE_FILE="$SNAPSHOT_DIR/files_before_behave.txt" AFTER_FILE="$SNAPSHOT_DIR/files_after_behave.txt" DIFF_FILE="$SNAPSHOT_DIR/files_diff.txt" - + # Define container name variable for consistency CONTAINER_NAME="Stirling-PDF-Security-Fat-with-login" - + capture_file_list "$CONTAINER_NAME" "$BEFORE_FILE" - + cd "testing/cucumber" if python -m behave; then # Wait 10 seconds before capturing the file list after tests echo "Waiting 5 seconds for any file operations to complete..." sleep 5 - + # Capture file list after running behave tests cd "$PROJECT_ROOT" capture_file_list "$CONTAINER_NAME" "$AFTER_FILE" - + # Compare file lists if compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"; then echo "No unexpected temporary files found." @@ -311,19 +311,19 @@ main() { echo "WARNING: Unexpected temporary files detected after behave tests!" failed_tests+=("Stirling-PDF-Regression-Temp-Files") fi - + passed_tests+=("Stirling-PDF-Regression") else failed_tests+=("Stirling-PDF-Regression") echo "Printing docker logs of failed regression" docker logs "$CONTAINER_NAME" echo "Printed docker logs of failed regression" - + # Still capture file list after failure for analysis # Wait 10 seconds before capturing the file list echo "Waiting 5 seconds before capturing file list..." sleep 10 - + cd "$PROJECT_ROOT" capture_file_list "$CONTAINER_NAME" "$AFTER_FILE" compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME" @@ -360,4 +360,4 @@ main() { fi } -main \ No newline at end of file +main diff --git a/testing/test_webpages.sh b/testing/test_webpages.sh index 2091995a..32bb92a7 100644 --- a/testing/test_webpages.sh +++ b/testing/test_webpages.sh @@ -9,7 +9,7 @@ check_webpage() { local result_file="$3" # Use curl to fetch the page with timeout - response=$(curl -s -w "\n%{http_code}" --max-time $timeout "$full_url") + response=$(curl -b cookies.txt -s -w "\n%{http_code}" --max-time $timeout "$full_url") if [ $? -ne 0 ]; then echo "FAILED - Connection error or timeout $full_url" >> "$result_file" return 1 @@ -75,13 +75,13 @@ test_all_urls() { ((total_count++)) ((url_index++)) - + # Run the check in background test_url "$url" "$base_url" "$tmp_dir" "$url_index" & - + # Track the job ((active_jobs++)) - + # If we've reached max_parallel, wait for a job to finish if [ $active_jobs -ge $max_parallel ]; then wait -n # Wait for any child process to exit @@ -97,7 +97,7 @@ test_all_urls() { if [ -f "${tmp_dir}/result_${i}.txt" ]; then cat "${tmp_dir}/result_${i}.txt" fi - + if [ -f "${tmp_dir}/failed_${i}" ]; then failed_count=$((failed_count + $(cat "${tmp_dir}/failed_${i}"))) fi @@ -158,6 +158,9 @@ main() { exit 1 fi + curl -s $base_url/session/expire/all + curl -s -c cookies.txt $base_url/session + # Run tests using the URL list if test_all_urls "$url_file" "$base_url" "$max_parallel"; then echo "All webpage tests passed!" @@ -171,4 +174,4 @@ main() { # Run main if script is executed directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" -fi \ No newline at end of file +fi From e30776cd4d30e745d8bb7247207cc2a473a9c067 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Thu, 27 Mar 2025 18:51:20 +0100 Subject: [PATCH 43/61] Update adminSettings.html --- src/main/resources/templates/adminSettings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/adminSettings.html b/src/main/resources/templates/adminSettings.html index 13c51a68..a3759f9f 100644 --- a/src/main/resources/templates/adminSettings.html +++ b/src/main/resources/templates/adminSettings.html @@ -52,7 +52,7 @@ Change User's Role - analytics Usage Statistics From 762571f42bf2784c4882ce7cb43b8ffd42591930 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Fri, 28 Mar 2025 13:01:31 +0100 Subject: [PATCH 44/61] logout process --- .../SPDF/config/EndpointInterceptor.java | 16 ++--- .../session/AnonymusSessionRegistry.java | 2 - .../AnonymusSessionStatusController.java | 1 - .../security/SecurityConfiguration.java | 3 + .../session/CustomHttpSessionListener.java | 65 +++++++++++++++++-- .../session/PreLogoutDataCaptureHandler.java | 45 +++++++++++++ .../security/session/SessionScheduled.java | 8 +-- .../controller/web/AccountWebController.java | 1 - .../resources/templates/adminSettings.html | 10 +-- 9 files changed, 123 insertions(+), 28 deletions(-) create mode 100644 src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index 4ec70721..b58c5a11 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.config; +import java.security.Principal; + import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -39,6 +41,9 @@ public class EndpointInterceptor implements HandlerInterceptor { } if ("GET".equalsIgnoreCase(request.getMethod())) { + + Principal principal = request.getUserPrincipal(); + if ("/".equals(request.getRequestURI()) || "/login".equals(request.getRequestURI()) || "/home".equals(request.getRequestURI()) @@ -55,20 +60,15 @@ public class EndpointInterceptor implements HandlerInterceptor { || request.getRequestURI().endsWith(".webmanifest") || request.getRequestURI().contains("/files/")) { return true; - } else { + } else if (principal != null) { if (session == null) { session = request.getSession(true); } + final HttpSession finalSession = session; String sessionId = finalSession.getId(); - // Den aktuellen Benutzer (principalName) aus der Session ermitteln. - // Es wird angenommen, dass das Attribut "principalName" in der Session gesetzt - // wurde. - final String currentPrincipal = - finalSession.getAttribute("principalName") != null - ? finalSession.getAttribute("principalName").toString() - : "unknown"; + final String currentPrincipal = principal.getName(); // Zähle alle nicht abgelaufenen Sessions des aktuellen Benutzers. long userSessions = diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java index a315619f..5cbb967a 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java @@ -42,8 +42,6 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt return; } - session.setAttribute("principalName", "anonymousUser"); - // Speichern des Erstellungszeitpunkts Date creationTime = new Date(); diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java index 79e632f1..5fbc3f78 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java @@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; -import lombok.extern.slf4j.Slf4j; @RestController public class AnonymusSessionStatusController { diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index 6ff37a9d..4f0b8537 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -36,6 +36,7 @@ import stirling.software.SPDF.config.security.oauth2.CustomOAuth2UserService; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationFailureHandler; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationSuccessHandler; import stirling.software.SPDF.config.security.saml2.CustomSaml2ResponseAuthenticationConverter; +import stirling.software.SPDF.config.security.session.PreLogoutDataCaptureHandler; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.User; @@ -158,6 +159,8 @@ public class SecurityConfiguration { http.logout( logout -> logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) + .addLogoutHandler( + new PreLogoutDataCaptureHandler(sessionRegistry)) .logoutSuccessHandler( new CustomLogoutSuccessHandler(applicationProperties)) .clearAuthentication(true) diff --git a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java index 05291591..f8ed023c 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java @@ -10,6 +10,7 @@ import java.util.Date; import java.util.List; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; @@ -33,13 +34,20 @@ import stirling.software.SPDF.model.SessionEntity; public class CustomHttpSessionListener implements HttpSessionListener, SessionsInterface { private final SessionPersistentRegistry sessionPersistentRegistry; + private final boolean loginEnabled; + private final boolean runningEE; @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m private Duration defaultMaxInactiveInterval; - public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) { + public CustomHttpSessionListener( + SessionPersistentRegistry sessionPersistentRegistry, + @Qualifier("loginEnabled") boolean loginEnabled, + @Qualifier("runningEE") boolean runningEE) { super(); this.sessionPersistentRegistry = sessionPersistentRegistry; + this.loginEnabled = loginEnabled; + this.runningEE = runningEE; } @Override @@ -110,18 +118,46 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI if (principalName == null) { return; } - session.setAttribute("principalName", principalName); - if ("anonymousUser".equals(principalName)) { - log.info("Principal is anonymousUser"); + if ("anonymousUser".equals(principalName) && loginEnabled) { + return; } int allNonExpiredSessions = getAllNonExpiredSessions().size(); - if (allNonExpiredSessions >= getMaxUserSessions()) { + + allNonExpiredSessions = + getAllSessions().stream() + .filter(s -> !s.isExpired()) + .filter(s -> s.getPrincipalName().equals(principalName)) + .filter(s -> "anonymousUser".equals(principalName) && !loginEnabled) + .peek(s -> log.info("Session {}", s.getPrincipalName())) + .toList() + .size(); + + int all = + getAllSessions().stream() + .filter(s -> !s.isExpired() && s.getPrincipalName().equals(principalName)) + .toList() + .size(); + boolean isAnonymousUserWithoutLogin = "anonymousUser".equals(principalName) && loginEnabled; + log.info( + "all {} allNonExpiredSessions {} {} isAnonymousUserWithoutLogin {}", + all, + allNonExpiredSessions, + getMaxUserSessions(), + isAnonymousUserWithoutLogin); + + if (allNonExpiredSessions >= getMaxApplicationSessions() && !isAnonymousUserWithoutLogin) { log.info("Session {} Expired=TRUE", session.getId()); sessionPersistentRegistry.expireSession(session.getId()); sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); // if (allNonExpiredSessions > getMaxUserSessions()) { // enforceMaxSessionsForPrincipal(principalName); // } + } else if (all >= getMaxUserSessions() && !isAnonymousUserWithoutLogin) { + enforceMaxSessionsForPrincipal(principalName); + log.info("Session {} Expired=TRUE", principalName); + } else if (isAnonymousUserWithoutLogin) { + sessionPersistentRegistry.expireSession(session.getId()); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); } else { log.info("Session created: {}", principalName); sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName); @@ -164,7 +200,6 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI if (session == null) { return; } - SessionInformation sessionsInfo = sessionPersistentRegistry.getSessionInformation(session.getId()); if (sessionsInfo == null) { @@ -197,4 +232,22 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI sessionPersistentRegistry.removeSessionInformation(session.getId()); log.info("Session {} wurde Expired=TRUE", session.getId()); } + + // Get the maximum number of sessions + @Override + public int getMaxApplicationSessions() { + if (runningEE) { + return Integer.MAX_VALUE; + } + return getMaxUserSessions() * 10; + } + + // Get the maximum number of user sessions + @Override + public int getMaxUserSessions() { + if (runningEE) { + return Integer.MAX_VALUE; + } + return 3; + } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java b/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java new file mode 100644 index 00000000..af796c7f --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java @@ -0,0 +1,45 @@ +package stirling.software.SPDF.config.security.session; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@AllArgsConstructor +public class PreLogoutDataCaptureHandler implements LogoutHandler { + + private final SessionPersistentRegistry sessionPersistentRegistry; + + @Override + public void logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + + String sessionId = session.getId(); + if (sessionId == null) { + return; + } + String path = request.getServletPath(); + if (path == null) { + return; + } + if (!"/logout".equals(path)) { + return; + } + log.debug("Session ID: {} Principal: {}", sessionId, authentication.getPrincipal()); + sessionPersistentRegistry.expireSession(sessionId); + sessionPersistentRegistry.removeSessionInformation(sessionId); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java index 5fe82f09..25ba4532 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java @@ -38,6 +38,7 @@ public class SessionScheduled { } else if (principal instanceof String stringPrincipal) { // Skip anonymousUser if login is enabled if ("anonymousUser".equals(stringPrincipal) && loginEnabledValue) { + sessionPersistentRegistry.expireAllSessionsByPrincipalName(stringPrincipal); continue; } } @@ -49,18 +50,13 @@ public class SessionScheduled { Instant expirationTime = lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); if (now.isAfter(expirationTime)) { - log.info( - "SessionID: {} expiration time: {} Current time: {}", - sessionInformation.getSessionId(), - expirationTime, - now); sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); sessionInformation.expireNow(); if (authentication != null && principal.equals(authentication.getPrincipal())) { authentication.setAuthenticated(false); } SecurityContextHolder.clearContext(); - log.info( + log.debug( "Session expired for principal: {} SessionID: {}", principal, sessionInformation.getSessionId()); diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 17018408..f093d662 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -348,7 +348,6 @@ public class AccountWebController { model.addAttribute("maxSessions", maxSessions); model.addAttribute("maxUserSessions", maxUserSessions); model.addAttribute("sessionCount", sessionCount); - model.addAttribute("maxEnterpriseUsers", applicationProperties.getPremium().getMaxUsers()); model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers()); return "adminSettings"; } diff --git a/src/main/resources/templates/adminSettings.html b/src/main/resources/templates/adminSettings.html index a3759f9f..5ffecd57 100644 --- a/src/main/resources/templates/adminSettings.html +++ b/src/main/resources/templates/adminSettings.html @@ -59,8 +59,8 @@
- Total Users: + Total Users: + Active Users: @@ -70,11 +70,13 @@ Total - Sessions: + Sessions: + Total - Sessions: / + Sessions: + /
From fe4d2823aa2c1669bf39b89b09ec18f34127b2b9 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Fri, 28 Mar 2025 21:53:17 +0100 Subject: [PATCH 45/61] clean up --- .../SPDF/config/EndpointInterceptor.java | 56 ++++++++++----- .../anonymus/session/AnonymusSessionInfo.java | 13 ++-- .../session/AnonymusSessionRegistry.java | 45 +++--------- .../session/AnonymusSessionService.java | 38 +++++------ .../AnonymusSessionStatusController.java | 21 ++++-- .../config/interfaces/SessionsInterface.java | 6 -- .../security/UserAuthenticationFilter.java | 23 +++++-- .../session/CustomHttpSessionListener.java | 68 ++++++------------- .../session/PreLogoutDataCaptureHandler.java | 6 ++ .../session/SessionPersistentRegistry.java | 54 ++++++++------- .../security/session/SessionRepository.java | 2 - .../security/session/SessionScheduled.java | 6 +- .../session/SessionStatusController.java | 27 +++++++- .../resources/templates/adminSettings.html | 49 ++++++------- 14 files changed, 214 insertions(+), 200 deletions(-) diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index b58c5a11..8b8d7521 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -2,7 +2,6 @@ package stirling.software.SPDF.config; import java.security.Principal; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -20,15 +19,11 @@ public class EndpointInterceptor implements HandlerInterceptor { private final EndpointConfiguration endpointConfiguration; private final SessionsInterface sessionsInterface; - private boolean loginEnabled = true; public EndpointInterceptor( - EndpointConfiguration endpointConfiguration, - SessionsInterface sessionsInterface, - @Qualifier("loginEnabled") boolean loginEnabled) { + EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) { this.endpointConfiguration = endpointConfiguration; this.sessionsInterface = sessionsInterface; - this.loginEnabled = loginEnabled; } @Override @@ -44,6 +39,7 @@ public class EndpointInterceptor implements HandlerInterceptor { Principal principal = request.getUserPrincipal(); + // allowlist for public or static routes if ("/".equals(request.getRequestURI()) || "/login".equals(request.getRequestURI()) || "/home".equals(request.getRequestURI()) @@ -70,7 +66,6 @@ public class EndpointInterceptor implements HandlerInterceptor { final String currentPrincipal = principal.getName(); - // Zähle alle nicht abgelaufenen Sessions des aktuellen Benutzers. long userSessions = sessionsInterface.getAllSessions().stream() .filter( @@ -80,25 +75,19 @@ public class EndpointInterceptor implements HandlerInterceptor { s.getPrincipalName())) .count(); - // Zähle alle nicht abgelaufenen Sessions in der Anwendung. long totalSessions = sessionsInterface.getAllSessions().stream() .filter(s -> !s.isExpired()) .count(); - log.info( - "Aktive Sessions für {}: {} (max: {}) | Gesamt: {} (max: {})", + log.debug( + "Active sessions for {}: {} (max: {}) | Total: {} (max: {})", currentPrincipal, userSessions, sessionsInterface.getMaxUserSessions(), totalSessions, sessionsInterface.getMaxApplicationSessions()); - // Prüfe die Grenzen: - // Falls entweder die Benutzersessions oder die Anwendungssessions das Limit - // erreicht haben - // und die aktuelle Session noch NICHT registriert ist, dann wird ein Fehler - // zurückgegeben. boolean isCurrentSessionRegistered = sessionsInterface.getAllSessions().stream() .filter(s -> !s.isExpired()) @@ -114,8 +103,40 @@ public class EndpointInterceptor implements HandlerInterceptor { return false; } - // Wenn die Session noch nicht registriert ist, registriere sie; andernfalls update - // den Last-Request. + // If session is not registered yet, register it; otherwise, update the last request + // timestamp. + if (!isCurrentSessionRegistered) { + log.info("Register session: {}", sessionId); + sessionsInterface.registerSession(finalSession); + } else { + log.info("Update session last request: {}", sessionId); + sessionsInterface.updateSessionLastRequest(sessionId); + } + return true; + } else if (principal == null) { + if (session == null) { + session = request.getSession(true); + } + final HttpSession finalSession = session; + String sessionId = finalSession.getId(); + + long totalSessions = + sessionsInterface.getAllSessions().stream() + .filter(s -> !s.isExpired()) + .count(); + boolean isCurrentSessionRegistered = + sessionsInterface.getAllSessions().stream() + .filter(s -> !s.isExpired()) + .anyMatch(s -> s.getSessionId().equals(sessionId)); + + if (totalSessions >= sessionsInterface.getMaxApplicationSessions() + && !isCurrentSessionRegistered) { + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + "Max sessions reached for this user. To continue on this device, please" + + " close your session in another browser."); + return false; + } if (!isCurrentSessionRegistered) { log.info("Register session: {}", sessionId); sessionsInterface.registerSession(finalSession); @@ -128,6 +149,7 @@ public class EndpointInterceptor implements HandlerInterceptor { } String requestURI = request.getRequestURI(); + // Check if endpoint is enabled in config if (!endpointConfiguration.isEndpointEnabled(requestURI)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled"); return false; diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java index 5543deac..54beaa46 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java @@ -5,13 +5,16 @@ import java.util.Date; import jakarta.servlet.http.HttpSession; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Setter; import lombok.ToString; import stirling.software.SPDF.config.interfaces.SessionsModelInterface; @Setter -@ToString(exclude = "session") +@ToString(exclude = "session") // exclude session from toString to avoid verbose output or sensitive +// data +@AllArgsConstructor public class AnonymusSessionInfo implements SessionsModelInterface { private static final String principalName = "anonymousUser"; private HttpSession session; @@ -22,14 +25,6 @@ public class AnonymusSessionInfo implements SessionsModelInterface { private Date lastRequest; private Boolean expired; - public AnonymusSessionInfo( - HttpSession session, Date createdAt, Date lastRequest, Boolean expired) { - this.session = session; - this.createdAt = createdAt; - this.lastRequest = lastRequest; - this.expired = expired; - } - public HttpSession getSession() { return session; } diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java index 5cbb967a..af21da93 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionRegistry.java @@ -4,7 +4,6 @@ import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collection; -import java.util.Comparator; import java.util.Date; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -28,7 +27,7 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m private Duration defaultMaxInactiveInterval; - // Map zur Speicherung der Sessions inkl. Timestamp + // Map for storing sessions including timestamp private static final Map sessions = new ConcurrentHashMap<>(); @Override @@ -42,7 +41,7 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt return; } - // Speichern des Erstellungszeitpunkts + // Save creation timestamp Date creationTime = new Date(); int allNonExpiredSessions = getAllNonExpiredSessions().size(); @@ -78,11 +77,11 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt if (now.isAfter(expirationTime)) { sessionsInfo.setExpired(true); session.invalidate(); - log.info("Session {} wurde Expired=TRUE", session.getId()); + log.debug("Session {} expired=TRUE", session.getId()); } } - // Make a session as expired + // Mark a single session as expired public void expireSession(String sessionId) { if (sessions.containsKey(sessionId)) { AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId); @@ -90,12 +89,12 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt try { sessionInfo.getSession().invalidate(); } catch (IllegalStateException e) { - log.info("Session {} ist bereits invalidiert", sessionInfo.getSession().getId()); + log.debug("Session {} already invalidated", sessionInfo.getSession().getId()); } } } - // Make all sessions as expired + // Mark all sessions as expired public void expireAllSessions() { sessions.values() .forEach( @@ -106,12 +105,12 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt try { session.invalidate(); } catch (IllegalStateException e) { - log.info("Session {} ist bereits invalidiert", session.getId()); + log.debug("Session {} already invalidated", session.getId()); } }); } - // Mark all sessions as expired by username + // Expire all sessions by username public void expireAllSessionsByUsername(String username) { sessions.values().stream() .filter( @@ -127,27 +126,11 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt try { session.invalidate(); } catch (IllegalStateException e) { - log.info("Session {} ist bereits invalidiert", session.getId()); + log.debug("Session {} already invalidated", session.getId()); } }); } - @Override - public boolean isSessionValid(String sessionId) { - boolean exists = sessions.containsKey(sessionId); - boolean expired = exists ? sessions.get(sessionId).isExpired() : false; - return exists && !expired; - } - - @Override - public boolean isOldestNonExpiredSession(String sessionId) { - Collection nonExpiredSessions = getAllNonExpiredSessions(); - return nonExpiredSessions.stream() - .min(Comparator.comparing(SessionsModelInterface::getLastRequest)) - .map(oldest -> oldest.getSessionId().equals(sessionId)) - .orElse(false); - } - @Override public void updateSessionLastRequest(String sessionId) { if (sessions.containsKey(sessionId)) { @@ -174,21 +157,13 @@ public class AnonymusSessionRegistry implements HttpSessionListener, SessionsInt sessions.clear(); } - @Override - public Collection getAllNonExpiredSessionsBySessionId( - String sessionId) { - return sessions.values().stream() - .filter(info -> !info.isExpired() && info.getSessionId().equals(sessionId)) - .toList(); - } - @Override public void registerSession(HttpSession session) { if (!sessions.containsKey(session.getId())) { AnonymusSessionInfo sessionInfo = new AnonymusSessionInfo(session, new Date(), new Date(), false); sessions.put(session.getId(), sessionInfo); - log.info("Session {} wurde registriert", session.getId()); + log.debug("Session {} registered", session.getId()); } } diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java index cae44879..b0432eeb 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java @@ -4,16 +4,16 @@ import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; -import java.util.List; -import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; @Slf4j +@Component public class AnonymusSessionService { @Autowired private AnonymusSessionRegistry sessionRegistry; @@ -21,27 +21,25 @@ public class AnonymusSessionService { @Value("${server.servlet.session.timeout:120s}") // TODO: Change to 30m private Duration defaultMaxInactiveInterval; + // Runs every minute to expire inactive sessions @Scheduled(cron = "0 0/1 * * * ?") public void expireSessions() { Instant now = Instant.now(); - List allNonExpiredSessions = - sessionRegistry.getAllNonExpiredSessions().stream() - .map(s -> (AnonymusSessionInfo) s) - .collect(Collectors.toList()); - for (AnonymusSessionInfo sessionInformation : allNonExpiredSessions) { - Date lastRequest = sessionInformation.getLastRequest(); - int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); - Instant expirationTime = - lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); + sessionRegistry.getAllSessions().stream() + .filter(session -> !session.isExpired()) + .forEach( + session -> { + Date lastRequest = session.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant expirationTime = + lastRequest + .toInstant() + .plus(maxInactiveInterval, ChronoUnit.SECONDS); - if (now.isAfter(expirationTime)) { - log.info( - "SessionID: {} expiration time: {} Current time: {}", - sessionInformation.getSession().getId(), - expirationTime, - now); - sessionInformation.setExpired(true); - } - } + if (now.isAfter(expirationTime)) { + log.debug("Session expiration triggered"); + sessionRegistry.expireSession(session.getSessionId()); + } + }); } } diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java index 5fbc3f78..4496ab06 100644 --- a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java @@ -19,10 +19,11 @@ public class AnonymusSessionStatusController { public ResponseEntity getSessionStatus(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session == null) { + // No session found return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No session found"); } - boolean isActivSesssion = + boolean isActiveSession = sessionRegistry.getAllSessions().stream() .filter(s -> s.getSessionId().equals(session.getId())) .anyMatch(s -> !s.isExpired()); @@ -33,12 +34,17 @@ public class AnonymusSessionStatusController { long userSessions = sessionCount; int maxUserSessions = sessionRegistry.getMaxUserSessions(); - if (userSessions >= maxUserSessions && !isActivSesssion) { + // Session invalid or expired + if (userSessions >= maxUserSessions && !isActiveSession) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body("Session ungültig oder abgelaufen"); - } else if (session.getId() != null && isActivSesssion) { - return ResponseEntity.ok("Session gültig: " + session.getId()); - } else { + .body("Session invalid or expired"); + } + // Valid session + else if (session.getId() != null && isActiveSession) { + return ResponseEntity.ok("Valid session: " + session.getId()); + } + // Fallback message with session count + else { return ResponseEntity.ok("User has " + userSessions + " sessions"); } } @@ -47,6 +53,7 @@ public class AnonymusSessionStatusController { public ResponseEntity expireSession(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session != null) { + // Invalidate current session sessionRegistry.expireSession(session.getId()); return ResponseEntity.ok("Session invalidated"); } else { @@ -56,12 +63,14 @@ public class AnonymusSessionStatusController { @GetMapping("/session/expire/all") public ResponseEntity expireAllSessions() { + // Invalidate all sessions sessionRegistry.expireAllSessions(); return ResponseEntity.ok("All sessions invalidated"); } @GetMapping("/session/expire/{username}") public ResponseEntity expireAllSessionsByUsername(@PathVariable String username) { + // Invalidate all sessions for specific user sessionRegistry.expireAllSessionsByUsername(username); return ResponseEntity.ok("All sessions invalidated for user: " + username); } diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java index 60173fb6..d4d4314b 100644 --- a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java +++ b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java @@ -6,18 +6,12 @@ import jakarta.servlet.http.HttpSession; public interface SessionsInterface { - boolean isSessionValid(String sessionId); - - boolean isOldestNonExpiredSession(String sessionId); - void updateSessionLastRequest(String sessionId); Collection getAllSessions(); Collection getAllNonExpiredSessions(); - Collection getAllNonExpiredSessionsBySessionId(String sessionId); - void registerSession(HttpSession session); void removeSession(HttpSession session); diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index b2e9d184..b0684d75 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -58,13 +58,26 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (!loginEnabledValue) { // If login is not enabled, just pass all requests without authentication filterChain.doFilter(request, response); return; } String requestURI = request.getRequestURI(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // Check for session expiration (unsure if needed) + // if (authentication != null && authentication.isAuthenticated()) { + // String sessionId = request.getSession().getId(); + // SessionInformation sessionInfo = + // sessionPersistentRegistry.getSessionInformation(sessionId); + // + // if (sessionInfo != null && sessionInfo.isExpired()) { + // SecurityContextHolder.clearContext(); + // response.sendRedirect(request.getContextPath() + "/login?expired=true"); + // return; + // } + // } // Check for API key in the request headers if no authentication exists if (authentication == null || !authentication.isAuthenticated()) { @@ -110,10 +123,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { response.getWriter() .write( "Authentication required. Please provide a X-API-KEY in request" - + " header.\n" - + "This is found in Settings -> Account Settings -> API Key\n" - + "Alternatively you can disable authentication if this is" - + " unexpected"); + + " header.\n" + + "This is found in Settings -> Account Settings -> API Key\n" + + "Alternatively you can disable authentication if this is" + + " unexpected"); return; } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java index f8ed023c..00183d20 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java @@ -27,7 +27,6 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.interfaces.SessionsInterface; import stirling.software.SPDF.config.interfaces.SessionsModelInterface; import stirling.software.SPDF.config.security.UserUtils; -import stirling.software.SPDF.model.SessionEntity; @Component @Slf4j @@ -57,40 +56,11 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI .toList(); } - @Override - public Collection getAllNonExpiredSessionsBySessionId( - String sessionId) { - return sessionPersistentRegistry.getAllNonExpiredSessionsBySessionId(sessionId).stream() - .map(session -> (SessionsModelInterface) session) - .toList(); - } - @Override public Collection getAllSessions() { return new ArrayList<>(sessionPersistentRegistry.getAllSessions()); } - @Override - public boolean isSessionValid(String sessionId) { - List allSessions = sessionPersistentRegistry.getAllSessions(); - // gib zurück ob ist expired - return allSessions.stream() - .anyMatch( - session -> - session.getSessionId().equals(sessionId) && !session.isExpired()); - } - - @Override - public boolean isOldestNonExpiredSession(String sessionId) { - log.info("isOldestNonExpiredSession for sessionId: {}", sessionId); - List nonExpiredSessions = - sessionPersistentRegistry.getAllSessionsNotExpired(); - return nonExpiredSessions.stream() - .min(Comparator.comparing(SessionEntity::getLastRequest)) - .map(oldest -> oldest.getSessionId().equals(sessionId)) - .orElse(false); - } - @Override public void updateSessionLastRequest(String sessionId) { sessionPersistentRegistry.refreshLastRequest(sessionId); @@ -121,16 +91,20 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI if ("anonymousUser".equals(principalName) && loginEnabled) { return; } - int allNonExpiredSessions = getAllNonExpiredSessions().size(); - allNonExpiredSessions = - getAllSessions().stream() - .filter(s -> !s.isExpired()) - .filter(s -> s.getPrincipalName().equals(principalName)) - .filter(s -> "anonymousUser".equals(principalName) && !loginEnabled) - .peek(s -> log.info("Session {}", s.getPrincipalName())) - .toList() - .size(); + int allNonExpiredSessions; + + if ("anonymousUser".equals(principalName) && !loginEnabled) { + allNonExpiredSessions = + (int) getAllSessions().stream().filter(s -> !s.isExpired()).count(); + } else { + allNonExpiredSessions = + (int) + getAllSessions().stream() + .filter(s -> !s.isExpired()) + .filter(s -> s.getPrincipalName().equals(principalName)) + .count(); + } int all = getAllSessions().stream() @@ -138,7 +112,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI .toList() .size(); boolean isAnonymousUserWithoutLogin = "anonymousUser".equals(principalName) && loginEnabled; - log.info( + log.debug( "all {} allNonExpiredSessions {} {} isAnonymousUserWithoutLogin {}", all, allNonExpiredSessions, @@ -146,7 +120,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI isAnonymousUserWithoutLogin); if (allNonExpiredSessions >= getMaxApplicationSessions() && !isAnonymousUserWithoutLogin) { - log.info("Session {} Expired=TRUE", session.getId()); + log.debug("Session {} Expired=TRUE", session.getId()); sessionPersistentRegistry.expireSession(session.getId()); sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); // if (allNonExpiredSessions > getMaxUserSessions()) { @@ -154,12 +128,12 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI // } } else if (all >= getMaxUserSessions() && !isAnonymousUserWithoutLogin) { enforceMaxSessionsForPrincipal(principalName); - log.info("Session {} Expired=TRUE", principalName); + log.debug("Session {} Expired=TRUE", session.getId()); } else if (isAnonymousUserWithoutLogin) { sessionPersistentRegistry.expireSession(session.getId()); sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); } else { - log.info("Session created: {}", principalName); + log.debug("Session created: {}", session.getId()); sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName); } } @@ -175,7 +149,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI int maxAllowed = getMaxUserSessions(); if (userSessions.size() > maxAllowed) { int sessionsToRemove = userSessions.size() - maxAllowed; - log.info( + log.debug( "User {} has {} active sessions, removing {} oldest session(s).", principalName, userSessions.size(), @@ -186,7 +160,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI // die die Session anhand der Session-ID invalidieren und entfernen. sessionPersistentRegistry.expireSession(sessionModel.getSessionId()); sessionPersistentRegistry.removeSessionInformation(sessionModel.getSessionId()); - log.info( + log.debug( "Removed session {} for principal {}", sessionModel.getSessionId(), principalName); @@ -216,7 +190,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI sessionPersistentRegistry.expireSession(session.getId()); session.invalidate(); sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); - log.info("Session {} wurde Expired=TRUE", session.getId()); + log.debug("Session {} expired=TRUE", session.getId()); } } @@ -230,7 +204,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI sessionPersistentRegistry.expireSession(session.getId()); session.invalidate(); sessionPersistentRegistry.removeSessionInformation(session.getId()); - log.info("Session {} wurde Expired=TRUE", session.getId()); + log.debug("Session {} expired=TRUE", session.getId()); } // Get the maximum number of sessions diff --git a/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java b/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java index af796c7f..7821927d 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java @@ -31,14 +31,20 @@ public class PreLogoutDataCaptureHandler implements LogoutHandler { if (sessionId == null) { return; } + String path = request.getServletPath(); if (path == null) { return; } + + // Only handle explicit logout requests if (!"/logout".equals(path)) { return; } + log.debug("Session ID: {} Principal: {}", sessionId, authentication.getPrincipal()); + + // Mark the session as expired and remove its record sessionPersistentRegistry.expireSession(sessionId); sessionPersistentRegistry.removeSessionInformation(sessionId); } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java index 72ea5364..40eca54a 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java @@ -76,11 +76,17 @@ public class SessionPersistentRegistry implements SessionRegistry { String principalName = UserUtils.getUsernameFromPrincipal(principal); if (principalName != null) { + + int sessionUserCount = getAllSessions(principalName, false).size(); + + if (sessionUserCount >= getMaxUserSessions()) { + return; + } SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId); if (sessionEntity == null) { sessionEntity = new SessionEntity(); sessionEntity.setSessionId(sessionId); - log.info("Registering new session for principal: {}", principalName); + log.debug("Registering new session for principal: {}", principalName); } sessionEntity.setPrincipalName(principalName); sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date @@ -128,7 +134,7 @@ public class SessionPersistentRegistry implements SessionRegistry { .collect(Collectors.toList()); if (nonExpiredSessions.isEmpty()) { - log.info("Keine nicht abgelaufenen Sessions für principal {} gefunden", principalName); + log.debug("No active sessions found for principal {}", principalName); return; } @@ -141,8 +147,8 @@ public class SessionPersistentRegistry implements SessionRegistry { SessionEntity oldestSession = oldestSessionOpt.get(); expireSession(oldestSession.getSessionId()); removeSessionInformation(oldestSession.getSessionId()); - log.info( - "Die älteste Session {} für principal {} wurde als expired markiert", + log.debug( + "Oldest session {} for principal {} has been marked as expired", oldestSession.getSessionId(), principalName); } @@ -161,11 +167,6 @@ public class SessionPersistentRegistry implements SessionRegistry { return null; } - // Retrieve all non-expired sessions - public List getAllNonExpiredSessionsBySessionId(String sessionId) { - return sessionRepository.findBySessionIdAndExpired(sessionId, false); - } - // Retrieve all non-expired sessions public List getAllSessionsNotExpired() { return sessionRepository.findByExpired(false); @@ -183,6 +184,7 @@ public class SessionPersistentRegistry implements SessionRegistry { SessionEntity sessionEntity = sessionEntityOpt.get(); sessionEntity.setExpired(true); // Set expired to true sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionId); } } @@ -192,6 +194,7 @@ public class SessionPersistentRegistry implements SessionRegistry { for (SessionEntity sessionEntity : sessionEntities) { sessionEntity.setExpired(true); // Set expired to true sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionEntity.getSessionId()); } } @@ -201,28 +204,29 @@ public class SessionPersistentRegistry implements SessionRegistry { for (SessionEntity sessionEntity : sessionEntities) { sessionEntity.setExpired(true); // Set expired to true sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionEntity.getSessionId()); } } // Mark all sessions as expired for a given principal name public void expireAllSessionsByPrincipalName(String principalName) { List sessionEntities = sessionRepository.findByPrincipalName(principalName); - log.info("Session entities: {}", sessionEntities.size()); + log.debug("Session entities: {}", sessionEntities.size()); for (SessionEntity sessionEntity : sessionEntities) { - log.info( + log.debug( "Session expired: {} {} {}", sessionEntity.getPrincipalName(), sessionEntity.isExpired(), sessionEntity.getSessionId()); sessionEntity.setExpired(true); // Set expired to true removeSessionInformation(sessionEntity.getSessionId()); - // sessionRepository.flush(); } + sessionEntities = sessionRepository.findByPrincipalName(principalName); - log.info("Session entities: {}", sessionEntities.size()); + log.debug("Session entities: {}", sessionEntities.size()); for (SessionEntity sessionEntity : sessionEntities) { if (sessionEntity.getPrincipalName().equals(principalName)) { - log.info("Session expired: {}", sessionEntity.getSessionId()); + log.debug("Session expired: {}", sessionEntity.getSessionId()); } } } @@ -238,19 +242,19 @@ public class SessionPersistentRegistry implements SessionRegistry { } // Update session details by principal name - public void updateSessionByPrincipalName( - String principalName, boolean expired, Date lastRequest) { - sessionRepository.saveByPrincipalName(expired, lastRequest, principalName); - } + // public void updateSessionByPrincipalName( + // String principalName, boolean expired, Date lastRequest) { + // sessionRepository.saveByPrincipalName(expired, lastRequest, principalName); + // } // Update session details by session ID - public void updateSessionBySessionId(String sessionId) { - SessionEntity sessionEntity = getSessionEntity(sessionId); - if (sessionEntity != null) { - sessionEntity.setLastRequest(new Date()); - sessionRepository.save(sessionEntity); - } - } + // public void updateSessionBySessionId(String sessionId) { + // SessionEntity sessionEntity = getSessionEntity(sessionId); + // if (sessionEntity != null) { + // sessionEntity.setLastRequest(new Date()); + // sessionRepository.save(sessionEntity); + // } + // } // Find the latest session for a given principal name public Optional findLatestSession(String principalName) { diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java index 0c4109a7..160a0375 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java @@ -21,8 +21,6 @@ public interface SessionRepository extends JpaRepository SessionEntity findBySessionId(String sessionId); - List findBySessionIdAndExpired(String sessionId, boolean expired); - void deleteByPrincipalName(String principalName); @Modifying diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java index 25ba4532..dec1e668 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java @@ -36,7 +36,7 @@ public class SessionScheduled { if (principal == null) { continue; } else if (principal instanceof String stringPrincipal) { - // Skip anonymousUser if login is enabled + // Expire anonymousUser sessions if login is enabled if ("anonymousUser".equals(stringPrincipal) && loginEnabledValue) { sessionPersistentRegistry.expireAllSessionsByPrincipalName(stringPrincipal); continue; @@ -52,10 +52,14 @@ public class SessionScheduled { if (now.isAfter(expirationTime)) { sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); sessionInformation.expireNow(); + + // Invalidate current authentication if expired session belongs to current user if (authentication != null && principal.equals(authentication.getPrincipal())) { authentication.setAuthenticated(false); } + SecurityContextHolder.clearContext(); + log.debug( "Session expired for principal: {} SessionID: {}", principal, diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java index 26109e99..1e928a6f 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java @@ -15,15 +15,30 @@ import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; + import lombok.extern.slf4j.Slf4j; + import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; @RestController +@Slf4j public class SessionStatusController { @Autowired private SessionPersistentRegistry sessionPersistentRegistry; + // Returns the current session ID or 401 if no session exists + @GetMapping("/session") + public ResponseEntity getSession(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("No session found"); + } else { + return ResponseEntity.ok(session.getId()); + } + } + + // Checks if the session is active and valid according to user session limits @GetMapping("/session/status") public ResponseEntity getSessionStatus(HttpServletRequest request) { HttpSession session = request.getSession(false); @@ -46,20 +61,23 @@ public class SessionStatusController { int userSessions = allSessions.size(); int maxUserSessions = sessionPersistentRegistry.getMaxUserSessions(); + // Check if the current session is valid or expired based on the session registry if (userSessions >= maxUserSessions && !isActivSession) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body("Session ungültig oder abgelaufen"); + .body("Session invalid or expired"); } else if (session.getId() != null && isActivSession) { - return ResponseEntity.ok("Session gültig: " + session.getId()); + return ResponseEntity.ok("Valid session: " + session.getId()); } else { return ResponseEntity.ok( "User: " + username + " has " + userSessions + " sessions"); } } else { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Session ungültig oder abgelaufen"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Session invalid or expired"); } } + // Invalidates the current session @GetMapping("/session/expire") public ResponseEntity expireSession(HttpServletRequest request) { HttpSession session = request.getSession(false); @@ -71,12 +89,15 @@ public class SessionStatusController { } } + // Invalidates all sessions @GetMapping("/session/expire/all") public ResponseEntity expireAllSessions() { + log.debug("Expire all sessions"); sessionPersistentRegistry.expireAllSessions(); return ResponseEntity.ok("All sessions invalidated"); } + // Invalidates all sessions for a specific user, only if requested by the same user @GetMapping("/session/expire/{username}") public ResponseEntity expireAllSessionsByUsername(@PathVariable String username) { SecurityContext cxt = SecurityContextHolder.getContext(); diff --git a/src/main/resources/templates/adminSettings.html b/src/main/resources/templates/adminSettings.html index 5ffecd57..9942e946 100644 --- a/src/main/resources/templates/adminSettings.html +++ b/src/main/resources/templates/adminSettings.html @@ -3,8 +3,7 @@ xmlns:th="https://www.thymeleaf.org"> - +