diff --git a/.github/workflows/develop-pipeline.yml b/.github/workflows/develop-pipeline.yml index e53e77acf..05fc77488 100644 --- a/.github/workflows/develop-pipeline.yml +++ b/.github/workflows/develop-pipeline.yml @@ -29,10 +29,10 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set Up JDK 25 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: '25' distribution: 'temurin' @@ -47,14 +47,14 @@ jobs: continue-on-error: true - name: Publish Backend Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2 if: always() with: files: booklore-api/build/test-results/**/*.xml check_name: Backend Test Results - name: Upload Backend Test Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: backend-test-reports @@ -82,18 +82,27 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set Up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: - node-version: '22' + node-version: '24' cache: 'npm' cache-dependency-path: booklore-ui/package-lock.json - name: Install Frontend Dependencies working-directory: ./booklore-ui - run: npm ci --force + run: npm ci + + - name: Audit Frontend Dependencies + working-directory: ./booklore-ui + run: npm audit --audit-level=high + + - name: Validate Dependency Tree + working-directory: ./booklore-ui + run: npm ls --depth=0 + - name: Execute Frontend Tests id: frontend_tests @@ -104,14 +113,14 @@ jobs: continue-on-error: true - name: Publish Frontend Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2 if: always() with: files: booklore-ui/test-results/vitest-results.xml check_name: Frontend Test Results - name: Upload Frontend Test Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: frontend-test-reports @@ -136,7 +145,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 @@ -161,15 +170,24 @@ jobs: # Native Angular build # ---------------------------------------- - name: Set Up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: - node-version: '22' + node-version: '24' cache: 'npm' cache-dependency-path: booklore-ui/package-lock.json - name: Install Frontend Dependencies working-directory: ./booklore-ui - run: npm ci --force + run: npm ci + + - name: Audit Frontend Dependencies + working-directory: ./booklore-ui + run: npm audit --audit-level=high + + - name: Validate Dependency Tree + working-directory: ./booklore-ui + run: npm ls --depth=0 + - name: Build Angular App working-directory: ./booklore-ui @@ -179,7 +197,7 @@ jobs: # Native Gradle build # ---------------------------------------- - name: Set Up JDK 25 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: '25' distribution: 'temurin' @@ -204,24 +222,24 @@ jobs: # Environment setup # ---------------------------------------- - name: Set Up QEMU for Multi-Arch Builds - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set Up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 # ---------------------------------------- # Docker login (pushes & internal PRs only) # ---------------------------------------- - name: Authenticate to Docker Hub if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Authenticate to GitHub Container Registry if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -232,7 +250,7 @@ jobs: # ---------------------------------------- - name: Build and push Docker image if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.ci diff --git a/.github/workflows/master-pipeline.yml b/.github/workflows/master-pipeline.yml index 82ed25c55..d51f0817e 100644 --- a/.github/workflows/master-pipeline.yml +++ b/.github/workflows/master-pipeline.yml @@ -13,7 +13,7 @@ jobs: base_ref: ${{ steps.get_base.outputs.base_ref }} steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 - name: Get Base Ref @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set Up JDK 25 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: '25' distribution: 'temurin' @@ -59,14 +59,14 @@ jobs: continue-on-error: true - name: Publish Backend Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2 if: always() with: files: booklore-api/build/test-results/**/*.xml check_name: Backend Test Results - name: Upload Backend Test Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: backend-test-reports @@ -94,18 +94,27 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set Up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: - node-version: '22' + node-version: '24' cache: 'npm' cache-dependency-path: booklore-ui/package-lock.json - name: Install Frontend Dependencies working-directory: ./booklore-ui - run: npm ci --force + run: npm ci + + - name: Audit Frontend Dependencies + working-directory: ./booklore-ui + run: npm audit --audit-level=high + + - name: Validate Dependency Tree + working-directory: ./booklore-ui + run: npm ls --depth=0 + - name: Execute Frontend Tests id: frontend_tests @@ -116,14 +125,14 @@ jobs: continue-on-error: true - name: Publish Frontend Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2 if: always() with: files: booklore-ui/test-results/vitest-results.xml check_name: Frontend Test Results - name: Upload Frontend Test Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: frontend-test-reports @@ -148,28 +157,28 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Authenticate to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Authenticate to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - name: Set Up QEMU for Multi-Architecture Builds - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set Up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Retrieve Latest Master Version Tag id: get_version @@ -238,15 +247,24 @@ jobs: # Native Angular build # ---------------------------------------- - name: Set Up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: - node-version: '22' + node-version: '24' cache: 'npm' cache-dependency-path: booklore-ui/package-lock.json - name: Install Frontend Dependencies working-directory: ./booklore-ui - run: npm ci --force + run: npm ci + + - name: Audit Frontend Dependencies + working-directory: ./booklore-ui + run: npm audit --audit-level=high + + - name: Validate Dependency Tree + working-directory: ./booklore-ui + run: npm ls --depth=0 + - name: Build Angular App working-directory: ./booklore-ui @@ -256,7 +274,7 @@ jobs: # Native Gradle build # ---------------------------------------- - name: Set Up JDK 25 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: '25' distribution: 'temurin' @@ -281,7 +299,7 @@ jobs: # Docker build & push # ---------------------------------------- - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.ci @@ -303,7 +321,7 @@ jobs: type=registry,ref=ghcr.io/booklore-app/booklore:buildcache,mode=max - name: Update GitHub Release Draft - uses: release-drafter/release-drafter@v6 + uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6 with: tag: ${{ env.new_tag }} name: "Release ${{ env.new_tag }}" diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index ffa87ab64..2e2ba4954 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -24,7 +24,7 @@ jobs: has_migrations: ${{ steps.check_migrations.outputs.has_migrations }} steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout Base Branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.base_ref }} fetch-depth: 0 @@ -78,7 +78,7 @@ jobs: migrate - name: Checkout Head Branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.head_ref }} fetch-depth: 0 diff --git a/Dockerfile b/Dockerfile index a45739992..5593656d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build the Angular app -FROM node:22-alpine AS angular-build +FROM node:24-alpine AS angular-build WORKDIR /angular-app diff --git a/booklore-api/src/main/java/org/booklore/config/WebSocketConfig.java b/booklore-api/src/main/java/org/booklore/config/WebSocketConfig.java index c85065bf7..383c7bdec 100644 --- a/booklore-api/src/main/java/org/booklore/config/WebSocketConfig.java +++ b/booklore-api/src/main/java/org/booklore/config/WebSocketConfig.java @@ -3,21 +3,26 @@ package org.booklore.config; import org.booklore.config.security.interceptor.WebSocketAuthInterceptor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import java.util.Arrays; + @Slf4j @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final WebSocketAuthInterceptor webSocketAuthInterceptor; + private final Environment env; - public WebSocketConfig(WebSocketAuthInterceptor webSocketAuthInterceptor) { + public WebSocketConfig(WebSocketAuthInterceptor webSocketAuthInterceptor, Environment env) { this.webSocketAuthInterceptor = webSocketAuthInterceptor; + this.env = env; } @Override @@ -29,8 +34,22 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/ws").setAllowedOrigins("*"); - log.info("WebSocket endpoint registered at /ws"); + String allowedOrigins = env.getProperty("app.cors.allowed-origins", "*").trim(); + var endpoint = registry.addEndpoint("/ws"); + if ("*".equals(allowedOrigins)) { + endpoint.setAllowedOriginPatterns("*"); + log.warn("WebSocket endpoint is configured to allow all origins (*). " + + "This is the default for backward compatibility, but it's recommended to set 'app.cors.allowed-origins' to an explicit list."); + } else if (allowedOrigins.isEmpty()) { + // No explicit origins configured: enforce same-origin check (Spring WebSocket default) + log.info("WebSocket endpoint registered at /ws (same-origin only)"); + } else { + String[] origins = Arrays.stream(allowedOrigins.split("\\s*,\\s*")) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + endpoint.setAllowedOriginPatterns(origins); + log.info("WebSocket endpoint registered at /ws with allowed origins: {}", Arrays.toString(origins)); + } } @Override diff --git a/booklore-api/src/main/java/org/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/org/booklore/config/security/SecurityConfig.java index ad5896b3e..0c05f1235 100644 --- a/booklore-api/src/main/java/org/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/org/booklore/config/security/SecurityConfig.java @@ -5,9 +5,11 @@ import org.booklore.config.security.service.OpdsUserDetailsService; import jakarta.servlet.DispatcherType; import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; +import org.booklore.util.FileService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -19,21 +21,41 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import java.io.IOException; +import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.net.InetAddress; +import java.net.Socket; +import java.util.Collections; + +@Slf4j @AllArgsConstructor @EnableMethodSecurity @Configuration public class SecurityConfig { + private static final Pattern ALLOWED = Pattern.compile("\\s*,\\s*"); private final OpdsUserDetailsService opdsUserDetailsService; private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter; + private final Environment env; private static final String[] COMMON_PUBLIC_ENDPOINTS = { "/ws/**", // WebSocket connections (auth handled in WebSocketAuthInterceptor) @@ -197,6 +219,10 @@ public class SecurityConfig { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers + .referrerPolicy(referrer -> referrer.policy( + ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + ) .authorizeHttpRequests(auth -> auth .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() .requestMatchers(publicEndpoints.toArray(new String[0])).permitAll() @@ -212,7 +238,11 @@ public class SecurityConfig { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) + .referrerPolicy(referrer -> referrer.policy( + ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + ) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); return http.build(); } @@ -224,10 +254,92 @@ public class SecurityConfig { return auth.build(); } + @Bean("noRedirectRestTemplate") + public RestTemplate noRedirectRestTemplate() { + return new RestTemplate( + new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { + super.prepareConnection(connection, httpMethod); + connection.setInstanceFollowRedirects(false); + if (connection instanceof HttpsURLConnection httpsConnection) { + String targetHost = FileService.getTargetHost(); + if (targetHost != null) { + // Set original host for SNI (even if connecting to IP) + SSLSocketFactory defaultFactory = httpsConnection.getSSLSocketFactory(); + httpsConnection.setSSLSocketFactory(new SniSSLSocketFactory(defaultFactory, targetHost)); + + httpsConnection.setHostnameVerifier((hostname, session) -> { + String expectedHost = FileService.getTargetHost(); + if (expectedHost != null) { + // Verify certificate against the original expected hostname, even if connecting via IP + return HttpsURLConnection.getDefaultHostnameVerifier().verify(expectedHost, session); + } + // Fallback: use default verifier for the hostname we connected to + return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session); + }); + } + } + } + } + ); + } + + private static class SniSSLSocketFactory extends SSLSocketFactory { + private final SSLSocketFactory delegate; + private final String targetHost; + + public SniSSLSocketFactory(SSLSocketFactory delegate, String targetHost) { + this.delegate = delegate; + this.targetHost = targetHost; + } + + @Override + public String[] getDefaultCipherSuites() { return delegate.getDefaultCipherSuites(); } + @Override + public String[] getSupportedCipherSuites() { return delegate.getSupportedCipherSuites(); } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { + // Pass targetHost instead of host (which is the IP) so the internal SSLSession gets the correct peer host + Socket socket = delegate.createSocket(s, targetHost, port, autoClose); + if (socket instanceof SSLSocket sslSocket) { + SNIHostName serverName = new SNIHostName(targetHost); + SSLParameters params = sslSocket.getSSLParameters(); + params.setServerNames(Collections.singletonList(serverName)); + // Explicitly set EndpointIdentificationAlgorithm so Java verifies the certificate against targetHost + params.setEndpointIdentificationAlgorithm("HTTPS"); + sslSocket.setSSLParameters(params); + } + return socket; + } + + @Override public Socket createSocket(String host, int port) throws IOException { return delegate.createSocket(host, port); } + @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { return delegate.createSocket(host, port, localHost, localPort); } + @Override public Socket createSocket(InetAddress host, int port) throws IOException { return delegate.createSocket(host, port); } + @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return delegate.createSocket(address, port, localAddress, localPort); } + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of("*")); + + String allowedOriginsStr = env.getProperty("app.cors.allowed-origins", "*").trim(); + if ("*".equals(allowedOriginsStr) || allowedOriginsStr.isEmpty()) { + log.warn( + "CORS is configured to allow all origins (*) because 'app.cors.allowed-origins' is '{}'. " + + "This maintains backward compatibility, but it's recommended to set it to an explicit origin list.", + allowedOriginsStr.isEmpty() ? "empty" : "*" + ); + configuration.setAllowedOriginPatterns(List.of("*")); + } else { + List origins = Arrays.stream(ALLOWED.split(allowedOriginsStr)) + .filter(s -> !s.isEmpty()) + .map(String::trim) + .toList(); + configuration.setAllowedOriginPatterns(origins); + } + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "Range", "If-None-Match")); configuration.setExposedHeaders(List.of("Content-Disposition", "Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Date")); @@ -238,4 +350,4 @@ public class SecurityConfig { return source; } -} \ No newline at end of file +} diff --git a/booklore-api/src/main/java/org/booklore/config/security/service/AuthRateLimitService.java b/booklore-api/src/main/java/org/booklore/config/security/service/AuthRateLimitService.java index 2898a1c49..11d0d8a2e 100644 --- a/booklore-api/src/main/java/org/booklore/config/security/service/AuthRateLimitService.java +++ b/booklore-api/src/main/java/org/booklore/config/security/service/AuthRateLimitService.java @@ -23,6 +23,7 @@ public class AuthRateLimitService { public AuthRateLimitService(AuditService auditService) { this.auditService = auditService; this.attemptCache = Caffeine.newBuilder() + .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(15)) .build(); } @@ -30,15 +31,30 @@ public class AuthRateLimitService { // --- Login rate limiting --- public void checkLoginRateLimit(String ip) { - checkRateLimit("login:" + ip, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for IP: " + ip); + checkRateLimit("login:ip:" + ip, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for IP: " + ip); + } + + public void checkLoginRateLimitByUsername(String username) { + String normalizedUsername = normalizeUsername(username); + checkRateLimit("login:user:" + normalizedUsername, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for username: " + normalizedUsername); } public void recordFailedLoginAttempt(String ip) { - recordFailedAttempt("login:" + ip); + recordFailedAttempt("login:ip:" + ip); + } + + public void recordFailedLoginAttemptByUsername(String username) { + String normalizedUsername = normalizeUsername(username); + recordFailedAttempt("login:user:" + normalizedUsername); } public void resetLoginAttempts(String ip) { - resetAttempts("login:" + ip); + resetAttempts("login:ip:" + ip); + } + + public void resetLoginAttemptsByUsername(String username) { + String normalizedUsername = normalizeUsername(username); + resetAttempts("login:user:" + normalizedUsername); } // --- Refresh token rate limiting --- @@ -72,4 +88,8 @@ public class AuthRateLimitService { private void resetAttempts(String key) { attemptCache.invalidate(key); } + + private String normalizeUsername(String username) { + return username != null ? username.trim().toLowerCase() : ""; + } } diff --git a/booklore-api/src/main/java/org/booklore/config/security/service/AuthenticationService.java b/booklore-api/src/main/java/org/booklore/config/security/service/AuthenticationService.java index ded58f79a..d6c905dfb 100644 --- a/booklore-api/src/main/java/org/booklore/config/security/service/AuthenticationService.java +++ b/booklore-api/src/main/java/org/booklore/config/security/service/AuthenticationService.java @@ -1,6 +1,7 @@ package org.booklore.config.security.service; -import lombok.AllArgsConstructor; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.booklore.config.AppProperties; import org.booklore.config.security.JwtUtils; @@ -31,10 +32,12 @@ import org.booklore.service.audit.AuditService; import org.booklore.util.RequestUtils; @Slf4j -@AllArgsConstructor +@RequiredArgsConstructor @Service public class AuthenticationService { + private String dummyPasswordHash; + private final AppProperties appProperties; private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; @@ -45,6 +48,11 @@ public class AuthenticationService { private final AuditService auditService; private final AuthRateLimitService authRateLimitService; + @PostConstruct + void initDummyHash() { + this.dummyPasswordHash = passwordEncoder.encode("_dummy_placeholder_for_timing_equalization_"); + } + public BookLoreUser getAuthenticatedUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null) { @@ -93,21 +101,31 @@ public class AuthenticationService { public ResponseEntity> loginUser(UserLoginRequest loginRequest) { String ip = RequestUtils.getCurrentRequest().getRemoteAddr(); + String username = loginRequest.getUsername(); authRateLimitService.checkLoginRateLimit(ip); + authRateLimitService.checkLoginRateLimitByUsername(username); - BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> { - auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + loginRequest.getUsername()); + BookLoreUserEntity user = userRepository.findByUsername(username).orElse(null); + + if (user == null) { + // Constant-time dummy BCrypt check prevents timing-based user enumeration: + // without this, unknown-user responses are ~3x faster than wrong-password responses. + passwordEncoder.matches(loginRequest.getPassword(), dummyPasswordHash); + auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + username); authRateLimitService.recordFailedLoginAttempt(ip); - return ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername()); - }); + authRateLimitService.recordFailedLoginAttemptByUsername(username); + throw ApiError.INVALID_CREDENTIALS.createException(); + } if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) { - auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + loginRequest.getUsername()); + auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + username); authRateLimitService.recordFailedLoginAttempt(ip); + authRateLimitService.recordFailedLoginAttemptByUsername(username); throw ApiError.INVALID_CREDENTIALS.createException(); } authRateLimitService.resetLoginAttempts(ip); + authRateLimitService.resetLoginAttemptsByUsername(username); return loginUser(user); } diff --git a/booklore-api/src/main/java/org/booklore/config/security/service/OpdsUserDetailsService.java b/booklore-api/src/main/java/org/booklore/config/security/service/OpdsUserDetailsService.java index ff3e77810..9d6146b00 100644 --- a/booklore-api/src/main/java/org/booklore/config/security/service/OpdsUserDetailsService.java +++ b/booklore-api/src/main/java/org/booklore/config/security/service/OpdsUserDetailsService.java @@ -22,7 +22,7 @@ public class OpdsUserDetailsService implements UserDetailsService { @Override public OpdsUserDetails loadUserByUsername(String username) throws UsernameNotFoundException { OpdsUserV2Entity userV2 = opdsUserV2Repository.findByUsername(username) - .orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username)); + .orElseThrow(() -> new UsernameNotFoundException("Invalid credentials")); OpdsUserV2 mappedCredential = opdsUserV2Mapper.toDto(userV2); return new OpdsUserDetails(mappedCredential); } diff --git a/booklore-api/src/main/java/org/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/org/booklore/controller/BookMediaController.java index 22bc8e94b..25eb3101c 100644 --- a/booklore-api/src/main/java/org/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/org/booklore/controller/BookMediaController.java @@ -1,6 +1,7 @@ package org.booklore.controller; import org.booklore.service.AuthorMetadataService; +import org.booklore.config.security.annotation.CheckBookAccess; import org.booklore.service.book.BookService; import org.booklore.service.bookdrop.BookDropService; import org.booklore.service.reader.CbxReaderService; @@ -32,6 +33,7 @@ public class BookMediaController { @Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a specific book.") @ApiResponse(responseCode = "200", description = "Book thumbnail returned successfully") @GetMapping("/book/{bookId}/thumbnail") + @CheckBookAccess(bookIdParam = "bookId") public ResponseEntity getBookThumbnail(@Parameter(description = "ID of the book") @PathVariable long bookId) { return ResponseEntity.ok(bookService.getBookThumbnail(bookId)); } @@ -39,6 +41,7 @@ public class BookMediaController { @Operation(summary = "Get book cover", description = "Retrieve the cover image for a specific book.") @ApiResponse(responseCode = "200", description = "Book cover returned successfully") @GetMapping("/book/{bookId}/cover") + @CheckBookAccess(bookIdParam = "bookId") public ResponseEntity getBookCover(@Parameter(description = "ID of the book") @PathVariable long bookId) { return ResponseEntity.ok(bookService.getBookCover(bookId)); } @@ -46,6 +49,7 @@ public class BookMediaController { @Operation(summary = "Get audiobook thumbnail", description = "Retrieve the audiobook thumbnail image for a specific book.") @ApiResponse(responseCode = "200", description = "Audiobook thumbnail returned successfully") @GetMapping("/book/{bookId}/audiobook-thumbnail") + @CheckBookAccess(bookIdParam = "bookId") public ResponseEntity getAudiobookThumbnail(@Parameter(description = "ID of the book") @PathVariable long bookId) { return ResponseEntity.ok(bookService.getAudiobookThumbnail(bookId)); } @@ -53,6 +57,7 @@ public class BookMediaController { @Operation(summary = "Get audiobook cover", description = "Retrieve the audiobook cover image for a specific book.") @ApiResponse(responseCode = "200", description = "Audiobook cover returned successfully") @GetMapping("/book/{bookId}/audiobook-cover") + @CheckBookAccess(bookIdParam = "bookId") public ResponseEntity getAudiobookCover(@Parameter(description = "ID of the book") @PathVariable long bookId) { return ResponseEntity.ok(bookService.getAudiobookCover(bookId)); } @@ -60,6 +65,7 @@ public class BookMediaController { @Operation(summary = "Get CBX page as image", description = "Retrieve a specific page from a CBX book as an image.") @ApiResponse(responseCode = "200", description = "CBX page image returned successfully") @GetMapping("/book/{bookId}/cbx/pages/{pageNumber}") + @CheckBookAccess(bookIdParam = "bookId") public void getCbxPage( @Parameter(description = "ID of the book") @PathVariable Long bookId, @Parameter(description = "Page number to retrieve") @PathVariable int pageNumber, diff --git a/booklore-api/src/main/java/org/booklore/controller/SetupController.java b/booklore-api/src/main/java/org/booklore/controller/SetupController.java index 5eef5c867..5bf6e7ae5 100644 --- a/booklore-api/src/main/java/org/booklore/controller/SetupController.java +++ b/booklore-api/src/main/java/org/booklore/controller/SetupController.java @@ -1,5 +1,6 @@ package org.booklore.controller; +import jakarta.validation.Valid; import org.booklore.exception.ErrorResponse; import org.booklore.model.dto.request.InitialUserRequest; import org.booklore.model.dto.response.SuccessResponse; @@ -35,7 +36,7 @@ public class SetupController { @ApiResponse(responseCode = "200", description = "Admin user created successfully") @PostMapping public ResponseEntity setupFirstUser( - @Parameter(description = "Initial user request") @RequestBody InitialUserRequest request) { + @Parameter(description = "Initial user request") @RequestBody @Valid InitialUserRequest request) { if (userProvisioningService.isInitialUserAlreadyProvisioned()) { return ResponseEntity.status(403).body(new ErrorResponse(403, "Setup is disabled after the first user is created.")); } diff --git a/booklore-api/src/main/java/org/booklore/model/dto/UserCreateRequest.java b/booklore-api/src/main/java/org/booklore/model/dto/UserCreateRequest.java index f33e892a5..05816b4ee 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/UserCreateRequest.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/UserCreateRequest.java @@ -1,14 +1,27 @@ package org.booklore.model.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; import java.util.Set; @Data public class UserCreateRequest { + + @NotBlank private String username; + + @NotBlank + @Size(min = 8, max = 72, message = "Password must be at least 8 characters long") private String password; + + @NotBlank private String name; + + @NotBlank + @Email private String email; private boolean permissionUpload; diff --git a/booklore-api/src/main/java/org/booklore/model/dto/request/InitialUserRequest.java b/booklore-api/src/main/java/org/booklore/model/dto/request/InitialUserRequest.java index ffcd44919..9911cd75b 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/request/InitialUserRequest.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/request/InitialUserRequest.java @@ -2,6 +2,7 @@ package org.booklore.model.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; @Data @@ -17,5 +18,6 @@ public class InitialUserRequest { private String name; @NotBlank + @Size(min = 8, max = 72, message = "Password must be between 8 and 72 characters long") private String password; } diff --git a/booklore-api/src/main/java/org/booklore/service/metadata/extractor/Fb2MetadataExtractor.java b/booklore-api/src/main/java/org/booklore/service/metadata/extractor/Fb2MetadataExtractor.java index eb8b87ce8..0356cb5c9 100644 --- a/booklore-api/src/main/java/org/booklore/service/metadata/extractor/Fb2MetadataExtractor.java +++ b/booklore-api/src/main/java/org/booklore/service/metadata/extractor/Fb2MetadataExtractor.java @@ -1,6 +1,7 @@ package org.booklore.service.metadata.extractor; import org.booklore.model.dto.BookMetadata; +import org.booklore.util.SecureXmlUtils; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; @@ -9,7 +10,6 @@ import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; -import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import java.io.File; @@ -37,9 +37,7 @@ public class Fb2MetadataExtractor implements FileMetadataExtractor { @Override public byte[] extractCover(File file) { try (InputStream inputStream = getInputStream(file)) { - DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); - dbf.setNamespaceAware(true); - dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + DocumentBuilderFactory dbf = SecureXmlUtils.createSecureDocumentBuilderFactory(true); DocumentBuilder builder = dbf.newDocumentBuilder(); Document doc = builder.parse(inputStream); @@ -93,9 +91,7 @@ public class Fb2MetadataExtractor implements FileMetadataExtractor { @Override public BookMetadata extractMetadata(File file) { try (InputStream inputStream = getInputStream(file)) { - DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); - dbf.setNamespaceAware(true); - dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + DocumentBuilderFactory dbf = SecureXmlUtils.createSecureDocumentBuilderFactory(true); DocumentBuilder builder = dbf.newDocumentBuilder(); Document doc = builder.parse(inputStream); diff --git a/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java b/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java index dd99559af..8e2f0ba58 100644 --- a/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java +++ b/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java @@ -5,6 +5,12 @@ import com.github.junrar.rarfile.FileHeader; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.Unmarshaller; +import jakarta.xml.bind.ValidationEvent; +import javax.xml.XMLConstants; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.sax.SAXSource; +import org.xml.sax.InputSource; +import org.xml.sax.XMLReader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; @@ -47,10 +53,6 @@ public class CbxMetadataWriter implements MetadataWriter { private static final JAXBContext JAXB_CONTEXT; static { - // XXE protection: Disable external DTD and Schema access for security - System.setProperty("javax.xml.accessExternalDTD", ""); - System.setProperty("javax.xml.accessExternalSchema", ""); - try { JAXB_CONTEXT = JAXBContext.newInstance(ComicInfo.class); } catch (jakarta.xml.bind.JAXBException e) { @@ -447,20 +449,32 @@ public class CbxMetadataWriter implements MetadataWriter { } private ComicInfo parseComicInfo(InputStream xmlStream) throws Exception { + // Use a SAXSource with an explicitly secured XMLReader to prevent XXE injection. + // This is more robust than System.setProperty() because it is per-instance and + // cannot be inadvertently disabled by other code running in the same JVM. + SAXParserFactory spf = SAXParserFactory.newInstance(); + spf.setNamespaceAware(true); + spf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + spf.setFeature("http://xml.org/sax/features/external-general-entities", false); + spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + XMLReader xmlReader = spf.newSAXParser().getXMLReader(); + SAXSource saxSource = new SAXSource(xmlReader, new InputSource(xmlStream)); + Unmarshaller unmarshaller = JAXB_CONTEXT.createUnmarshaller(); unmarshaller.setEventHandler(event -> { - if (event.getSeverity() == jakarta.xml.bind.ValidationEvent.WARNING || - event.getSeverity() == jakarta.xml.bind.ValidationEvent.ERROR) { - log.warn("JAXB Parsing Issue: {} [Line: {}, Col: {}]", - event.getMessage(), - event.getLocator().getLineNumber(), + if (event.getSeverity() == ValidationEvent.WARNING || + event.getSeverity() == ValidationEvent.ERROR) { + log.warn("JAXB Parsing Issue: {} [Line: {}, Col: {}]", + event.getMessage(), + event.getLocator().getLineNumber(), event.getLocator().getColumnNumber()); } return true; // Continue processing }); - ComicInfo result = (ComicInfo) unmarshaller.unmarshal(xmlStream); - log.debug("CbxMetadataWriter: Parsed ComicInfo - Title: {}, Summary length: {}", - result.getTitle(), + ComicInfo result = (ComicInfo) unmarshaller.unmarshal(saxSource); + log.debug("CbxMetadataWriter: Parsed ComicInfo - Title: {}, Summary length: {}", + result.getTitle(), result.getSummary() != null ? result.getSummary().length() : 0); return result; } @@ -703,8 +717,12 @@ public class CbxMetadataWriter implements MetadataWriter { if (entryName == null || entryName.isBlank()) return false; String normalized = entryName.replace('\\', '/'); if (normalized.startsWith("/")) return false; - if (normalized.contains("../")) return false; if (normalized.contains("\0")) return false; + // Check each path component for ".." traversal. Splitting with -1 limit catches + // trailing separators and avoids missing ".." at the end of a path (e.g. "foo/.."). + for (String component : normalized.split("/", -1)) { + if ("..".equals(component)) return false; + } return true; } diff --git a/booklore-api/src/main/java/org/booklore/service/opds/OpdsFeedService.java b/booklore-api/src/main/java/org/booklore/service/opds/OpdsFeedService.java index 335895952..af146f9e6 100644 --- a/booklore-api/src/main/java/org/booklore/service/opds/OpdsFeedService.java +++ b/booklore-api/src/main/java/org/booklore/service/opds/OpdsFeedService.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service; import java.io.File; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -472,7 +473,7 @@ public class OpdsFeedService { String queryString = request.getQueryString(); if (queryString != null) { - java.util.Arrays.stream(queryString.split("&")) + Arrays.stream(queryString.split("&")) .filter(param -> !param.startsWith("page=") && !param.startsWith("size=")) .forEach(param -> result.append(param).append("&")); } diff --git a/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java b/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java index 81afb03cf..934cf754d 100644 --- a/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java +++ b/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java @@ -437,6 +437,13 @@ public class CbxReaderService { if (normalized.startsWith("__MACOSX/") || normalized.contains("/__MACOSX/")) { return false; } + // Prevent path traversal: reject any entry whose path contains ".." as a component. + // Checks split-by-/ to catch "foo/..", ".." alone, and not just "../" (with trailing slash). + for (String component : normalized.split("/", -1)) { + if ("..".equals(component)) { + return false; + } + } String baseName = baseName(normalized).toLowerCase(); if (baseName.startsWith("._") || baseName.startsWith(".")) { return false; diff --git a/booklore-api/src/main/java/org/booklore/service/reader/EpubReaderService.java b/booklore-api/src/main/java/org/booklore/service/reader/EpubReaderService.java index 298c20593..365835721 100644 --- a/booklore-api/src/main/java/org/booklore/service/reader/EpubReaderService.java +++ b/booklore-api/src/main/java/org/booklore/service/reader/EpubReaderService.java @@ -20,11 +20,13 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.apache.pdfbox.io.IOUtils; +import org.booklore.util.SecureXmlUtils; import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; +import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -63,12 +65,7 @@ public class EpubReaderService { private static final ThreadLocal DOCUMENT_BUILDER = ThreadLocal.withInitial(() -> { try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - return factory.newDocumentBuilder(); + return SecureXmlUtils.createSecureDocumentBuilder(true); } catch (ParserConfigurationException e) { throw new RuntimeException("Failed to create DocumentBuilder", e); } diff --git a/booklore-api/src/main/java/org/booklore/service/upload/FileUploadService.java b/booklore-api/src/main/java/org/booklore/service/upload/FileUploadService.java index 93c765cfc..603faadef 100644 --- a/booklore-api/src/main/java/org/booklore/service/upload/FileUploadService.java +++ b/booklore-api/src/main/java/org/booklore/service/upload/FileUploadService.java @@ -1,5 +1,6 @@ package org.booklore.service.upload; +import lombok.RequiredArgsConstructor; import org.booklore.config.AppProperties; import org.booklore.exception.ApiError; import org.booklore.mapper.AdditionalFileMapper; @@ -21,7 +22,7 @@ import org.booklore.service.file.FileMovingHelper; import org.booklore.service.monitoring.MonitoringRegistrationService; import org.booklore.service.metadata.extractor.MetadataExtractorFactory; import org.booklore.util.PathPatternResolver; -import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -262,7 +263,13 @@ public class FileUploadService { if (originalFileName == null) { throw new IllegalArgumentException("File must have a name"); } - return originalFileName; + // Prevent Path Traversal by extracting only the base file name + String cleanPath = StringUtils.cleanPath(originalFileName); + String baseFileName = StringUtils.getFilename(cleanPath); + if (baseFileName == null || baseFileName.isEmpty() || baseFileName.equals("..")) { + throw new IllegalArgumentException("Invalid filename"); + } + return baseFileName; } private BookFileExtension getFileExtension(String fileName) { diff --git a/booklore-api/src/main/java/org/booklore/util/FileService.java b/booklore-api/src/main/java/org/booklore/util/FileService.java index 844962001..11cb99eb1 100644 --- a/booklore-api/src/main/java/org/booklore/util/FileService.java +++ b/booklore-api/src/main/java/org/booklore/util/FileService.java @@ -11,22 +11,30 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; import java.util.Comparator; +import java.util.Iterator; import java.util.Set; import java.util.stream.Stream; @@ -35,9 +43,32 @@ import java.util.stream.Stream; @Service public class FileService { + private static final ThreadLocal TARGET_HOST_THREAD_LOCAL = new ThreadLocal<>(); + + public static String getTargetHost() { + return TARGET_HOST_THREAD_LOCAL.get(); + } + + public static void setTargetHost(String host) { + TARGET_HOST_THREAD_LOCAL.set(host); + } + + public static void clearTargetHost() { + TARGET_HOST_THREAD_LOCAL.remove(); + } + + static { + // Enable restricted headers to allow 'Host' header override for DNS rebinding protection + System.setProperty("sun.net.http.allowRestrictedHeaders", "true"); + } + private final AppProperties appProperties; private final RestTemplate restTemplate; private final AppSettingService appSettingService; + private final RestTemplate noRedirectRestTemplate; + + private static final int MAX_REDIRECTS = 5; + private static final double TARGET_COVER_ASPECT_RATIO = 1.5; private static final int SMART_CROP_COLOR_TOLERANCE = 30; @@ -58,6 +89,8 @@ public class FileService { private static final String JPEG_MIME_TYPE = "image/jpeg"; private static final String PNG_MIME_TYPE = "image/png"; private static final long MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024; + // 20 MP covers legitimate book covers and author photos with a comfortable safety margin. + private static final long MAX_IMAGE_PIXELS = 20_000_000L; private static final int THUMBNAIL_WIDTH = 250; private static final int THUMBNAIL_HEIGHT = 350; private static final int SQUARE_THUMBNAIL_SIZE = 250; @@ -137,6 +170,7 @@ public class FileService { return Paths.get(appProperties.getPathConfig(), "tools", "kepubify").toString(); } + // ======================================== // VALIDATION // ======================================== @@ -170,18 +204,34 @@ public class FileService { if (imageData == null || imageData.length == 0) { throw new IOException("Image data is null or empty"); } - try (InputStream is = new ByteArrayInputStream(imageData)) { - BufferedImage image = ImageIO.read(is); - if (image != null) { - return image; + + try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(imageData))) { + Iterator readers = ImageIO.getImageReaders(iis); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + + long pixelCount = (long) width * height; + if (pixelCount > MAX_IMAGE_PIXELS) { + throw new IOException(String.format("Rejected image: dimensions %dx%d (%d pixels) exceed limit %d — possible decompression bomb", + width, height, pixelCount, MAX_IMAGE_PIXELS)); + } + + return reader.read(0); + } finally { + reader.dispose(); + } } + } catch (IOException e) { + throw e; } catch (Exception e) { - log.warn("ImageIO/TwelveMonkeys decode failed (possibly unsupported format like AVIF/HEIC): {}", e.getMessage()); - return null; + throw new IOException("ImageIO decode failed (possibly unsupported format): " + e.getMessage(), e); } - log.warn("Unable to decode image - likely unsupported format (AVIF, HEIC, or SVG)"); - return null; + throw new IOException("Unable to decode image, likely unsupported format"); } public static BufferedImage resizeImage(BufferedImage originalImage, int width, int height) { @@ -217,31 +267,138 @@ public class FileService { public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException { try { - HttpHeaders headers = new HttpHeaders(); - headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)"); - headers.set(HttpHeaders.ACCEPT, "image/*"); - - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - imageUrl, - HttpMethod.GET, - entity, - byte[].class - ); - - // Validate and convert - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return readImage(response.getBody()); - } else { - throw new IOException("Failed to download image. HTTP Status: " + response.getStatusCode()); - } + return downloadImageFromUrlInternal(imageUrl); } catch (Exception e) { - log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage()); - throw new IOException("Failed to download image from URL: " + imageUrl + " - " + e.getMessage(), e); + log.warn("Failed to download image from {}: {}", imageUrl, e.getMessage()); + if (e instanceof IOException ioException) { + throw ioException; + } + throw new IOException("Failed to download image from " + imageUrl + ": " + e.getMessage(), e); } } + private BufferedImage downloadImageFromUrlInternal(String imageUrl) throws IOException { + String currentUrl = imageUrl; + int redirectCount = 0; + + try { + while (redirectCount <= MAX_REDIRECTS) { + URI uri = URI.create(currentUrl); + // Protocol validation + if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) { + throw new IOException("Only HTTP and HTTPS protocols are allowed"); + } + + String host = uri.getHost(); + if (host == null) { + throw new IOException("Invalid URL: no host found in " + currentUrl); + } + + // Resolve host to IP to prevent DNS rebinding (TOCTOU) + InetAddress[] inetAddresses = InetAddress.getAllByName(host); + if (inetAddresses.length == 0) { + throw new IOException("Could not resolve host: " + host); + } + for (InetAddress inetAddress : inetAddresses) { + if (isInternalAddress(inetAddress)) { + throw new SecurityException("URL points to a local or private internal network address: " + host + " (" + inetAddress.getHostAddress() + ")"); + } + } + String ipAddress = inetAddresses[0].getHostAddress(); + + // Set target host for SNI / Hostname verification in RestTemplate/SSLSocketFactory + setTargetHost(host); + + // Build request URL with IP address to ensure we connect to the validated address. + // We keep the original URI's path and query. + String portSuffix = (uri.getPort() != -1) ? ":" + uri.getPort() : ""; + String path = uri.getRawPath(); + if (path == null || path.isEmpty()) path = "/"; + String query = uri.getRawQuery(); + + // Handle IPv6 address formatting in URL + String hostInUrl = ipAddress; + if (ipAddress.contains(":")) { + hostInUrl = "[" + ipAddress + "]"; + } + String requestUrl = uri.getScheme() + "://" + hostInUrl + portSuffix + path + (query != null ? "?" + query : ""); + + HttpHeaders headers = new HttpHeaders(); + // Set original 'Host' header for server-side virtual hosting + headers.set(HttpHeaders.HOST, host); + headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)"); + headers.set(HttpHeaders.ACCEPT, "image/*"); + + HttpEntity entity = new HttpEntity<>(headers); + + log.debug("Downloading image via IP-based URL: {} (Original host: {})", requestUrl, host); + + ResponseEntity response = noRedirectRestTemplate.exchange( + requestUrl, + HttpMethod.GET, + entity, + byte[].class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return readImage(response.getBody()); + } else if (response.getStatusCode().is3xxRedirection()) { + String location = response.getHeaders().getFirst(HttpHeaders.LOCATION); + if (location == null) { + throw new IOException("Redirection response without Location header"); + } + // Resolve location against CURRENT URL (which has the hostname) + currentUrl = uri.resolve(location).toString(); + redirectCount++; + } else { + throw new IOException("Failed to download image. HTTP Status: " + response.getStatusCode()); + } + } + } finally { + // Ensure thread-local is cleared to prevent leakage to subsequent requests (container thread reuse) + clearTargetHost(); + } + + throw new IOException("Too many redirects (max " + MAX_REDIRECTS + ")"); + } + + private boolean isInternalAddress(InetAddress address) { + if (address.isLoopbackAddress() || address.isLinkLocalAddress() || + address.isSiteLocalAddress() || address.isAnyLocalAddress()) { + return true; + } + + byte[] addr = address.getAddress(); + // Check for IPv6 Unique Local Address (fc00::/7) + if (addr.length == 16) { + if ((addr[0] & 0xFE) == (byte) 0xFC) { + return true; + } + } + + // Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) + if (isIpv4MappedAddress(addr)) { + try { + byte[] ipv4Bytes = new byte[4]; + System.arraycopy(addr, 12, ipv4Bytes, 0, 4); + InetAddress ipv4Addr = InetAddress.getByAddress(ipv4Bytes); + return isInternalAddress(ipv4Addr); + } catch (java.net.UnknownHostException e) { + return false; + } + } + + return false; + } + + private boolean isIpv4MappedAddress(byte[] addr) { + if (addr.length != 16) return false; + for (int i = 0; i < 10; i++) { + if (addr[i] != 0) return false; + } + return (addr[10] == (byte) 0xFF) && (addr[11] == (byte) 0xFF); + } + // ======================================== // COVER OPERATIONS // ======================================== diff --git a/booklore-api/src/main/java/org/booklore/util/SecureXmlUtils.java b/booklore-api/src/main/java/org/booklore/util/SecureXmlUtils.java index f5fba2dad..45e3f48a5 100644 --- a/booklore-api/src/main/java/org/booklore/util/SecureXmlUtils.java +++ b/booklore-api/src/main/java/org/booklore/util/SecureXmlUtils.java @@ -12,7 +12,7 @@ import javax.xml.parsers.ParserConfigurationException; @UtilityClass public class SecureXmlUtils { - public static DocumentBuilderFactory createSecureDocumentBuilderFactory(boolean namespaceAware) { + public static DocumentBuilderFactory createSecureDocumentBuilderFactory(boolean namespaceAware) throws ParserConfigurationException { try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(namespaceAware); @@ -27,10 +27,8 @@ public class SecureXmlUtils { return factory; } catch (ParserConfigurationException e) { - log.warn("Failed to configure secure XML parser, using defaults: {}", e.getMessage()); - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(namespaceAware); - return factory; + log.error("CRITICAL: Failed to configure secure XML parser: {}", e.getMessage()); + throw e; } } diff --git a/booklore-api/src/main/resources/application.yaml b/booklore-api/src/main/resources/application.yaml index a9212bf16..61639be4b 100644 --- a/booklore-api/src/main/resources/application.yaml +++ b/booklore-api/src/main/resources/application.yaml @@ -2,6 +2,13 @@ app: path-config: '/app/data' bookdrop-folder: '/bookdrop' version: 'development' + + cors: + # Comma-separated list of allowed CORS origins. + # Leave empty (default) to allow same-origin requests only. + # Example: ALLOWED_ORIGINS=https://app.example.com,https://mobile.example.com + allowed-origins: ${ALLOWED_ORIGINS:*} + remote-auth: enabled: ${REMOTE_AUTH_ENABLED:false} create-new-users: ${REMOTE_AUTH_CREATE_NEW_USERS:true} diff --git a/booklore-api/src/test/java/org/booklore/util/FileServiceTest.java b/booklore-api/src/test/java/org/booklore/util/FileServiceTest.java index df3212699..c14b355e1 100644 --- a/booklore-api/src/test/java/org/booklore/util/FileServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/util/FileServiceTest.java @@ -18,6 +18,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.RestTemplate; import javax.imageio.ImageIO; @@ -61,7 +62,9 @@ class FileServiceTest { .build(); lenient().when(appSettingService.getAppSettings()).thenReturn(appSettings); - fileService = new FileService(appProperties, mock(RestTemplate.class), appSettingService); + RestTemplate mockRestTemplate = mock(RestTemplate.class); + RestTemplate mockNoRedirectRestTemplate = mock(RestTemplate.class); + fileService = new FileService(appProperties, mockRestTemplate, appSettingService, mockNoRedirectRestTemplate); } @Nested @@ -374,9 +377,9 @@ class FileServiceTest { } @Test - void invalidData_returnsNull() throws IOException { + void invalidData_throwsException() { byte[] invalidData = "not an image".getBytes(); - assertNull(FileService.readImage(invalidData)); + assertThrows(IOException.class, () -> FileService.readImage(invalidData)); } } @@ -485,11 +488,11 @@ class FileServiceTest { } @Test - void invalidImageData_skipsSave() { + void invalidImageData_throwsException() { byte[] invalidData = "not an image".getBytes(); Path outputPath = tempDir.resolve("invalid.jpg"); - assertDoesNotThrow(() -> + assertThrows(IOException.class, () -> FileService.saveImage(invalidData, outputPath.toString())); assertFalse(Files.exists(outputPath)); } @@ -913,12 +916,11 @@ class FileServiceTest { } @Test - void invalidImageBytes_skipsThumbnail() { + void invalidImageBytes_throwsException() { byte[] invalidData = "not an image".getBytes(); - assertDoesNotThrow(() -> + assertThrows(RuntimeException.class, () -> fileService.createThumbnailFromBytes(16L, invalidData)); - assertFalse(Files.exists(Path.of(fileService.getCoverFile(16L)))); } @Test @@ -1031,15 +1033,14 @@ class FileServiceTest { } @Test - void corruptImageData_skipsThumbnail() { + void corruptImageData_throwsException() { // Valid MIME type but corrupt image data byte[] corruptData = ("not an image but has jpeg mime type").getBytes(); MockMultipartFile corruptFile = new MockMultipartFile( "file", "corrupt.jpg", "image/jpeg", corruptData); - assertDoesNotThrow(() -> + assertThrows(RuntimeException.class, () -> fileService.createThumbnailFromFile(12L, corruptFile)); - assertFalse(Files.exists(Path.of(fileService.getCoverFile(12L)))); } @Test @@ -1176,7 +1177,7 @@ class FileServiceTest { .build(); lenient().when(appSettingServiceForNetwork.getAppSettings()).thenReturn(appSettings); - fileService = new FileService(appProperties, restTemplate, appSettingServiceForNetwork); + fileService = new FileService(appProperties, restTemplate, appSettingServiceForNetwork, restTemplate); } @Nested @@ -1187,17 +1188,17 @@ class FileServiceTest { @DisplayName("downloads and returns valid image") @Timeout(5) void downloadImageFromUrl_validImage_returnsBufferedImage() throws IOException { - String imageUrl = "http://example.com/image.jpg"; + String imageUrl = "http://1.1.1.1/image.jpg"; BufferedImage testImage = createTestImage(100, 100); byte[] imageBytes = imageToBytes(testImage); RestTemplate mockRestTemplate = mock(RestTemplate.class); AppSettingService mockAppSettingService = mock(AppSettingService.class); - FileService testFileService = new FileService(appProperties, mockRestTemplate, mockAppSettingService); + FileService testFileService = new FileService(appProperties, mockRestTemplate, mockAppSettingService, mockRestTemplate); ResponseEntity responseEntity = ResponseEntity.ok(imageBytes); when(mockRestTemplate.exchange( - eq(imageUrl), + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class) @@ -1214,10 +1215,10 @@ class FileServiceTest { @DisplayName("throws exception when response body is null") @Timeout(5) void downloadImageFromUrl_nullBody_throwsException() { - String imageUrl = "http://example.com/image.jpg"; + String imageUrl = "http://1.1.1.1/image.jpg"; ResponseEntity responseEntity = ResponseEntity.ok(null); when(restTemplate.exchange( - eq(imageUrl), + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class) @@ -1228,31 +1229,38 @@ class FileServiceTest { } @Test - @DisplayName("returns null when ImageIO cannot read bytes") + @DisplayName("throws IOException when ImageIO cannot read bytes") @Timeout(5) - void downloadImageFromUrl_invalidImageData_returnsNull() throws IOException { - String imageUrl = "http://example.com/image.jpg"; + void downloadImageFromUrl_invalidImageData_throwsException() throws IOException { + String imageUrl = "http://1.1.1.1/image.jpg"; byte[] invalidBytes = "not an image".getBytes(); ResponseEntity responseEntity = ResponseEntity.ok(invalidBytes); - when(restTemplate.exchange( - eq(imageUrl), + // Note: using ReflectionTestUtils to get the private mock if needed, + // but wait, setup() already created fileService with mocks. + // We just need to know which mock to use. + // The setup() creates and injects mockNoRedirectRestTemplate. + + // Let's use ReflectionTestUtils to mock the correct one since the field in test class is 'restTemplate' + RestTemplate noRedirectMock = (RestTemplate) ReflectionTestUtils.getField(fileService, "noRedirectRestTemplate"); + + when(noRedirectMock.exchange( + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class) )).thenReturn(responseEntity); - BufferedImage result = fileService.downloadImageFromUrl(imageUrl); - assertNull(result); + assertThrows(IOException.class, () -> fileService.downloadImageFromUrl(imageUrl)); } @Test @DisplayName("throws exception on HTTP error status") @Timeout(5) void downloadImageFromUrl_httpError_throwsException() { - String imageUrl = "http://example.com/image.jpg"; + String imageUrl = "http://1.1.1.1/image.jpg"; ResponseEntity responseEntity = ResponseEntity.notFound().build(); when(restTemplate.exchange( - eq(imageUrl), + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class) @@ -1271,14 +1279,14 @@ class FileServiceTest { @DisplayName("downloads and saves cover images successfully") @Timeout(5) void createThumbnailFromUrl_validImage_createsCoverAndThumbnail() throws IOException { - String imageUrl = "http://example.com/cover.jpg"; + String imageUrl = "http://1.1.1.1/cover.jpg"; long bookId = 42L; BufferedImage testImage = createTestImage(800, 1200); // Portrait image byte[] imageBytes = imageToBytes(testImage); ResponseEntity responseEntity = ResponseEntity.ok(imageBytes); when(restTemplate.exchange( - eq(imageUrl), + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class) @@ -1304,7 +1312,7 @@ class FileServiceTest { long bookId = 42L; when(restTemplate.exchange( - eq(imageUrl), + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class) diff --git a/booklore-ui/package-lock.json b/booklore-ui/package-lock.json index f7e0bb0b7..16ef893ef 100644 --- a/booklore-ui/package-lock.json +++ b/booklore-ui/package-lock.json @@ -20,7 +20,7 @@ "@angular/service-worker": "^21.1.5", "@iharbeck/ngx-virtual-scroller": "^20.0.0", "@jsverse/transloco": "^8.2.1", - "@primeng/themes": "^21.0.3", + "@primeuix/themes": "^2.0.3", "@stomp/rx-stomp": "^2.3.0", "@stomp/stompjs": "^7.3.0", "@tweenjs/tween.js": "^25.0.0", @@ -29,6 +29,7 @@ "chartjs-chart-matrix": "^3.0.0", "chartjs-plugin-datalabels": "^2.2.0", "date-fns": "^4.1.0", + "dompurify": "^3.3.1", "jwt-decode": "^4.0.0", "ng-lazyload-image": "^9.1.3", "ng2-charts": "^8.0.0", @@ -40,25 +41,25 @@ "rxjs": "^7.8.2", "showdown": "^2.1.0", "tslib": "^2.8.1", - "uuid": "^13.0.0", + "uuid": "^11.0.0", "ws": "^8.19.0", "zone.js": "^0.16.1" }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.2.3", "@analogjs/vitest-angular": "^2.2.3", - "@angular/build": "^21.1.4", - "@angular/cli": "^21.1.4", + "@angular/build": "^21.1.5", + "@angular/cli": "^21.1.5", "@angular/compiler-cli": "^21.1.5", + "@types/dompurify": "^3.0.5", "@types/node": "^25.3.0", "@types/showdown": "^2.0.6", - "@types/uuid": "^11.0.0", "@vitest/coverage-v8": "^4.0.17", "angular-eslint": "^21.2.0", - "eslint": "^10.0.0", + "eslint": "^9.39.3", "jsdom": "^28.1.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.56.0", + "typescript-eslint": "^8.56.1", "vitest": "^4.0.17" } }, @@ -335,13 +336,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2101.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.4.tgz", - "integrity": "sha512-3yyebORk+ovtO+LfDjIGbGCZhCMDAsyn9vkCljARj3sSshS4blOQBar0g+V3kYAweLT5Gf+rTKbN5jneOkBAFQ==", + "version": "0.2101.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.5.tgz", + "integrity": "sha512-eTo6wWzUW5AyBBLTbaUTpBHhGbZhzteErtNGklWkhjicCr/soNH+2mVtvg8bqA8sNreYffK1VXKFsq5NyMh5qg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.1.4", + "@angular-devkit/core": "21.1.5", "rxjs": "7.8.2" }, "bin": { @@ -354,13 +355,13 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.4.tgz", - "integrity": "sha512-ObPTI5gYCB1jGxTRhcqZ6oQVUBFVJ8GH4LksVuAiz0nFX7xxpzARWvlhq943EtnlovVlUd9I8fM3RQqjfGVVAQ==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.5.tgz", + "integrity": "sha512-KUKbllHvHefkAbTBjWNpRPyrpBqecW+6HBBAR+XNbKBuFTHkG+gxtuwMXNsvO5KECKwQphvQt5h3g05Xtaf0LQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "8.17.1", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.3", @@ -382,13 +383,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.4.tgz", - "integrity": "sha512-Nqq0ioCUxrbEX+L4KOarETcZZJNnJ1mAJ0ubO4VM91qnn8RBBM9SnQ91590TfC34Szk/wh+3+Uj6KUvTJNuegQ==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.5.tgz", + "integrity": "sha512-CGmoorQL5+mVCJEHwHWOrhSd1hFxB3h66i9wUDizJAEQUM3mSml5SiglHArpWY/G4GmFwi6XVe+Jm3U8J/mcFg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.1.4", + "@angular-devkit/core": "21.1.5", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.0.0", @@ -525,14 +526,14 @@ } }, "node_modules/@angular/build": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.4.tgz", - "integrity": "sha512-7CAAQPWFMMqod40ox5MOVB/CnoBXFDehyQhs0hls6lu7bOy/M0EDy0v6bERkyNGRz1mihWWBiCV8XzEinrlq1A==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.5.tgz", + "integrity": "sha512-v2eDinWKlSKuk5pyMMY8j5TMFW8HA9B1l13TrDDpxsRGAAzekg7TFNyuh1x9Y6Rq4Vn+8/8pCjMUPZigzWbMhQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2101.4", + "@angular-devkit/architect": "0.2101.5", "@babel/core": "7.28.5", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -575,7 +576,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.1.4", + "@angular/ssr": "^21.1.5", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -641,19 +642,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.4.tgz", - "integrity": "sha512-XsMHgxTvHGiXXrhYZz3zMZYhYU0gHdpoHKGiEKXwcx+S1KoYbIssyg6oF2Kq49ZaE0OYCTKjnvgDce6ZqdkJ/A==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.5.tgz", + "integrity": "sha512-ljqvAzSk8FKMaYW/aZhR+SXjudbQViYYkMlJvJUClGpokjDM9KfJWPX+QZfr2J+piW5yaaHmFaIMddO9QxkUDQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2101.4", - "@angular-devkit/core": "21.1.4", - "@angular-devkit/schematics": "21.1.4", + "@angular-devkit/architect": "0.2101.5", + "@angular-devkit/core": "21.1.5", + "@angular-devkit/schematics": "21.1.5", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.1.4", + "@schematics/angular": "21.1.5", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.46.2", "ini": "6.0.0", @@ -1865,110 +1866,139 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.1.tgz", - "integrity": "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.1", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.1.1" + "minimatch": "^3.1.2" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.1.tgz", - "integrity": "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@exodus/bytes": { @@ -2416,16 +2446,6 @@ } } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -3720,17 +3740,6 @@ "license": "MIT", "optional": true }, - "node_modules/@primeng/themes": { - "version": "21.0.4", - "resolved": "https://registry.npmjs.org/@primeng/themes/-/themes-21.0.4.tgz", - "integrity": "sha512-WDF8pj91K3ItGmUJB91T1mRj6ut3dCMz4/5rb1sZR6sjxP7jX06PT/uXkKF8sz60ewEEBbh3yK98Js/mKxev3A==", - "deprecated": "Deprecated. This package is no longer maintained. Please migrate to @primeuix/themes: https://www.npmjs.com/package/@primeuix/themes", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@primeuix/styled": "^0.7.4", - "@primeuix/themes": "^2.0.2" - } - }, "node_modules/@primeuix/motion": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/@primeuix/motion/-/motion-0.0.10.tgz", @@ -4361,14 +4370,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.4.tgz", - "integrity": "sha512-I1zdSNzdbrVCWpeE2NsZQmIoa9m0nlw4INgdGIkqUH6FgwvoGKC0RoOxKAmm6HHVJ48FE/sPI13dwAeK89ow5A==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.5.tgz", + "integrity": "sha512-AndJ17ePYUoqJqiIF9VaXbGAFfOqDcHuAxhwozsQlWDzwgQSOUC/WWeG9hKVCgMD6tE02Sxr2ova9DiBKsLQNg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.1.4", - "@angular-devkit/schematics": "21.1.4", + "@angular-devkit/core": "21.1.5", + "@angular-devkit/schematics": "21.1.5", "jsonc-parser": "3.3.1" }, "engines": { @@ -4517,48 +4526,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@tufjs/models/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@tweenjs/tween.js": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", @@ -4594,6 +4561,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4632,29 +4609,25 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", - "integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==", - "deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "uuid": "*" - } + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -4667,22 +4640,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "engines": { @@ -4698,14 +4671,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "engines": { @@ -4720,14 +4693,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4738,9 +4711,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -4755,15 +4728,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -4780,9 +4753,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -4794,18 +4767,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -4822,16 +4795,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4846,13 +4819,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4864,9 +4837,9 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5063,9 +5036,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -5096,9 +5069,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -5291,11 +5264,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", @@ -5370,13 +5346,16 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -6074,6 +6053,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -6325,30 +6313,33 @@ } }, "node_modules/eslint": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz", - "integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.0", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.0", - "eslint-visitor-keys": "^5.0.0", - "espree": "^11.1.0", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -6358,7 +6349,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.1.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6366,7 +6358,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -6413,9 +6405,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -6429,40 +6421,64 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "jackspeak": "^4.2.3" + "color-convert": "^2.0.1" }, "engines": { - "node": "20 || >=22" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "20 || >=22" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6485,48 +6501,32 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", - "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7058,46 +7058,17 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/gopd": { @@ -7338,48 +7309,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/ignore-walk/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/ignore-walk/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/immutable": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", @@ -7624,22 +7553,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -7943,6 +7856,13 @@ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -8205,16 +8125,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10392,16 +10312,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10510,16 +10430,16 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/validate-npm-package-license": { diff --git a/booklore-ui/package.json b/booklore-ui/package.json index c09913950..f4fd6d46e 100644 --- a/booklore-ui/package.json +++ b/booklore-ui/package.json @@ -24,7 +24,7 @@ "@angular/service-worker": "^21.1.5", "@iharbeck/ngx-virtual-scroller": "^20.0.0", "@jsverse/transloco": "^8.2.1", - "@primeng/themes": "^21.0.3", + "@primeuix/themes": "^2.0.3", "@stomp/rx-stomp": "^2.3.0", "@stomp/stompjs": "^7.3.0", "@tweenjs/tween.js": "^25.0.0", @@ -33,6 +33,7 @@ "chartjs-chart-matrix": "^3.0.0", "chartjs-plugin-datalabels": "^2.2.0", "date-fns": "^4.1.0", + "dompurify": "^3.3.1", "jwt-decode": "^4.0.0", "ng-lazyload-image": "^9.1.3", "ng2-charts": "^8.0.0", @@ -44,25 +45,32 @@ "rxjs": "^7.8.2", "showdown": "^2.1.0", "tslib": "^2.8.1", - "uuid": "^13.0.0", + "uuid": "^11.0.0", "ws": "^8.19.0", "zone.js": "^0.16.1" }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.2.3", "@analogjs/vitest-angular": "^2.2.3", - "@angular/build": "^21.1.4", - "@angular/cli": "^21.1.4", + "@angular/build": "^21.1.5", + "@angular/cli": "^21.1.5", "@angular/compiler-cli": "^21.1.5", + "@types/dompurify": "^3.0.5", "@types/node": "^25.3.0", "@types/showdown": "^2.0.6", - "@types/uuid": "^11.0.0", "@vitest/coverage-v8": "^4.0.17", "angular-eslint": "^21.2.0", - "eslint": "^10.0.0", + "eslint": "^9.39.3", "jsdom": "^28.1.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.56.0", + "typescript-eslint": "^8.56.1", "vitest": "^4.0.17" + }, + "overrides": { + "minimatch": "^10.2.1", + "ajv": "^8.18.0", + "eslint": { + "ajv": "^6.14.0" + } } } diff --git a/booklore-ui/src/app/shared/layout/component/theme-palette-extend.ts b/booklore-ui/src/app/shared/layout/component/theme-palette-extend.ts index 110ed5f72..ed400beb6 100644 --- a/booklore-ui/src/app/shared/layout/component/theme-palette-extend.ts +++ b/booklore-ui/src/app/shared/layout/component/theme-palette-extend.ts @@ -1,4 +1,4 @@ -import Aura from '@primeng/themes/aura'; +import Aura from '@primeuix/themes/aura'; type ColorPalette = Record; diff --git a/booklore-ui/src/app/shared/service/app-config.service.ts b/booklore-ui/src/app/shared/service/app-config.service.ts index 6dc1a957e..1ecf0df53 100644 --- a/booklore-ui/src/app/shared/service/app-config.service.ts +++ b/booklore-ui/src/app/shared/service/app-config.service.ts @@ -1,8 +1,8 @@ -import {DOCUMENT, isPlatformBrowser} from '@angular/common'; -import {effect, inject, Injectable, PLATFORM_ID, signal} from '@angular/core'; -import {$t, updatePreset, updateSurfacePalette} from '@primeng/themes'; -import Aura from '@primeng/themes/aura'; -import {AppState} from '../model/app-state.model'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { effect, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; +import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'; +import Aura from '@primeuix/themes/aura'; +import { AppState } from '../model/app-state.model'; type ColorPalette = Record; @@ -332,7 +332,7 @@ export class AppConfigService { constructor() { const initialState = this.loadAppState(); - this.appState.set({...initialState}); + this.appState.set({ ...initialState }); this.document.documentElement.classList.add('p-dark'); if (isPlatformBrowser(this.platformId)) { @@ -383,7 +383,7 @@ export class AppConfigService { if (primaryName === 'noir') { return { semantic: { - primary: {...surfacePalette}, + primary: { ...surfacePalette }, colorScheme: { dark: { primary: { @@ -430,6 +430,6 @@ export class AppConfigService { onPresetChange(): void { const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral'); const preset = this.getPresetExt(); - $t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({useDefaultOptions: true}); + $t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({ useDefaultOptions: true }); } } diff --git a/booklore-ui/src/app/shared/services/icon.service.ts b/booklore-ui/src/app/shared/services/icon.service.ts index 53bd9d2d8..f42504058 100644 --- a/booklore-ui/src/app/shared/services/icon.service.ts +++ b/booklore-ui/src/app/shared/services/icon.service.ts @@ -1,10 +1,11 @@ -import {inject, Injectable} from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {Observable, of} from 'rxjs'; -import {finalize, map, shareReplay, tap} from 'rxjs/operators'; -import {API_CONFIG} from '../../core/config/api-config'; -import {IconCacheService} from './icon-cache.service'; -import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { finalize, map, shareReplay, tap } from 'rxjs/operators'; +import { API_CONFIG } from '../../core/config/api-config'; +import { IconCacheService } from './icon-cache.service'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import DOMPurify from 'dompurify'; interface SvgIconData { svgName: string; @@ -25,7 +26,6 @@ interface SvgIconBatchResponse { } type IconContentMap = Record; - @Injectable({ providedIn: 'root' }) @@ -39,6 +39,16 @@ export class IconService { private iconCache = inject(IconCacheService); private sanitizer = inject(DomSanitizer); + /** + * Sanitizes SVG content using DOMPurify. + */ + private sanitizeSvgContent(content: string): string { + return DOMPurify.sanitize(content, { + USE_PROFILES: { svg: true }, + FORBID_TAGS: ['script', 'style', 'foreignObject'] + }); + } + preloadAllIcons(): Observable { if (this.preloadCache$) { return this.preloadCache$; @@ -47,12 +57,12 @@ export class IconService { this.preloadCache$ = this.http.get(`${this.baseUrl}/all/content`).pipe( tap((iconsMap) => { Object.entries(iconsMap).forEach(([iconName, content]) => { - const sanitized = this.sanitizer.bypassSecurityTrustHtml(content); + const sanitized = this.sanitizer.bypassSecurityTrustHtml(this.sanitizeSvgContent(content)); this.iconCache.cacheIcon(iconName, content, sanitized); }); }), map(() => void 0), - shareReplay({bufferSize: 1, refCount: false}), + shareReplay({ bufferSize: 1, refCount: false }), finalize(() => this.preloadCache$ = null) ); @@ -70,10 +80,10 @@ export class IconService { responseType: 'text' }).pipe( tap(content => { - const sanitized = this.sanitizer.bypassSecurityTrustHtml(content); + const sanitized = this.sanitizer.bypassSecurityTrustHtml(this.sanitizeSvgContent(content)); this.iconCache.cacheIcon(iconName, content, sanitized); }), - shareReplay({bufferSize: 1, refCount: true}), + shareReplay({ bufferSize: 1, refCount: true }), finalize(() => this.requestCache.delete(iconName)) ); @@ -113,13 +123,13 @@ export class IconService { } saveBatchSvgIcons(icons: SvgIconData[]): Observable { - return this.http.post(`${this.baseUrl}/batch`, {icons}).pipe( + return this.http.post(`${this.baseUrl}/batch`, { icons }).pipe( tap((response) => { response.results.forEach(result => { if (result.success) { const iconData = icons.find(icon => icon.svgName === result.iconName); if (iconData) { - const sanitized = this.sanitizer.bypassSecurityTrustHtml(iconData.svgData); + const sanitized = this.sanitizer.bypassSecurityTrustHtml(this.sanitizeSvgContent(iconData.svgData)); this.iconCache.cacheIcon(iconData.svgName, iconData.svgData, sanitized); } } diff --git a/booklore-ui/src/main.ts b/booklore-ui/src/main.ts index b365ab329..613522427 100644 --- a/booklore-ui/src/main.ts +++ b/booklore-ui/src/main.ts @@ -1,28 +1,28 @@ -import {provideHttpClient, withInterceptors} from '@angular/common/http'; -import {DialogService} from 'primeng/dynamicdialog'; -import {ConfirmationService, MessageService} from 'primeng/api'; -import {RxStompService} from './app/shared/websocket/rx-stomp.service'; -import {rxStompServiceFactory} from './app/shared/websocket/rx-stomp-service-factory'; -import {provideRouter, RouteReuseStrategy} from '@angular/router'; -import {CustomReuseStrategy} from './app/core/custom-reuse-strategy'; -import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; -import {providePrimeNG} from 'primeng/config'; -import {bootstrapApplication} from '@angular/platform-browser'; -import {AppComponent} from './app/app.component'; -import Aura from '@primeng/themes/aura'; -import {routes} from './app/app.routes'; -import {AuthInterceptorService} from './app/core/security/auth-interceptor.service'; -import {AuthService, websocketInitializer} from './app/shared/service/auth.service'; -import {OAuthStorage, provideOAuthClient} from 'angular-oauth2-oidc'; -import {inject, isDevMode, provideAppInitializer, provideZoneChangeDetection} from '@angular/core'; -import {initializeAuthFactory} from './app/core/security/auth-initializer'; -import {StartupService} from './app/shared/service/startup.service'; -import {provideCharts, withDefaultRegisterables} from 'ng2-charts'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { DialogService } from 'primeng/dynamicdialog'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { RxStompService } from './app/shared/websocket/rx-stomp.service'; +import { rxStompServiceFactory } from './app/shared/websocket/rx-stomp-service-factory'; +import { provideRouter, RouteReuseStrategy } from '@angular/router'; +import { CustomReuseStrategy } from './app/core/custom-reuse-strategy'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { providePrimeNG } from 'primeng/config'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import Aura from '@primeuix/themes/aura'; +import { routes } from './app/app.routes'; +import { AuthInterceptorService } from './app/core/security/auth-interceptor.service'; +import { AuthService, websocketInitializer } from './app/shared/service/auth.service'; +import { OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc'; +import { inject, isDevMode, provideAppInitializer, provideZoneChangeDetection } from '@angular/core'; +import { initializeAuthFactory } from './app/core/security/auth-initializer'; +import { StartupService } from './app/shared/service/startup.service'; +import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import ChartDataLabels from 'chartjs-plugin-datalabels'; -import {provideServiceWorker} from '@angular/service-worker'; -import {provideTransloco} from '@jsverse/transloco'; -import {AVAILABLE_LANGS, TranslocoInlineLoader} from './app/core/config/transloco-loader'; -import {initializeLanguage} from './app/core/config/language-initializer'; +import { provideServiceWorker } from '@angular/service-worker'; +import { provideTransloco } from '@jsverse/transloco'; +import { AVAILABLE_LANGS, TranslocoInlineLoader } from './app/core/config/transloco-loader'; +import { initializeLanguage } from './app/core/config/language-initializer'; export function storageFactory(): OAuthStorage { return localStorage; diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index 796b9f455..d185991a7 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -4,12 +4,13 @@ services: command: sh -c "cd /booklore-api && ./gradlew bootRun" ports: - "${BACKEND_PORT:-6060}:6060" - - "${REMOTE_DEBUG_PORT:-5005}:5005" + - "127.0.0.1:${REMOTE_DEBUG_PORT:-5005}:5005" environment: - DATABASE_URL=jdbc:mariadb://backend_db:3306/booklore - DATABASE_USERNAME=booklore - DATABASE_PASSWORD=booklore - REMOTE_DEBUG_ENABLED=true + - ALLOWED_ORIGINS=http://localhost:4200 stdin_open: true tty: true restart: unless-stopped diff --git a/example-docker/docker-compose.yml b/example-docker/docker-compose.yml index b7aabbda1..d7334bbc8 100644 --- a/example-docker/docker-compose.yml +++ b/example-docker/docker-compose.yml @@ -14,6 +14,7 @@ services: - DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container - SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production). - FORCE_DISABLE_OIDC=false # Set to 'true' to force-disable OIDC and allow internal login, regardless of UI config + # ALLOWED_ORIGINS= # Comma-separated list of allowed cross-origin URLs for CORS (e.g. https://app.example.com). Defaults to '*' (allow all) if unset. Leave empty to restrict to same-origin. depends_on: mariadb: condition: service_healthy