refactor(misc): Harden request handling, tighten defaults, and pin CI deps (#2878)

* refactor(misc): Harden request handling, tighten defaults, and pin CI dependencies

* feat(auth): normalize usernames for login rate limiting

* refactor(epub): fix XML document builder creation

* feat(http): enhance image download with redirect handling and DNS rebinding protection

* chore: upgrade Node.js version to 24 and add frontend dependency audit step

* feat(auth): refactor username normalization for login rate limiting and enhance image download security

* fix(metadata): simplify JAXB validation event handling by using direct import

* chore(pipeline): remove unnecessary --force flag from npm ci and format audit step

* chore(pipeline): streamline frontend dependency audits and add validation step

* fix(image): improve error handling for image processing and download, enforce pixel limits

* test(image): update tests

* fix(icon): update DOMPurify import and replace deprecated theme package

* fix(imports): update theme package imports and format code for consistency

* fix(cors): update allowed origins configuration to support wildcard and trim whitespace
This commit is contained in:
Balázs Szücs
2026-02-24 20:43:14 +01:00
committed by GitHub
parent 161c675f74
commit f3fffeadce
31 changed files with 989 additions and 626 deletions

View File

@@ -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

View File

@@ -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 }}"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<String> 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;
}
}
}

View File

@@ -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() : "";
}
}

View File

@@ -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<Map<String, String>> 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);
}

View File

@@ -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);
}

View File

@@ -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<Resource> 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<Resource> 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<Resource> 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<Resource> 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,

View File

@@ -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."));
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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("&"));
}

View File

@@ -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;

View File

@@ -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<DocumentBuilder> 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);
}

View File

@@ -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) {

View File

@@ -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<String> 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<ImageReader> 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<String> entity = new HttpEntity<>(headers);
ResponseEntity<byte[]> 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<String> entity = new HttpEntity<>(headers);
log.debug("Downloading image via IP-based URL: {} (Original host: {})", requestUrl, host);
ResponseEntity<byte[]> 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
// ========================================

View File

@@ -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;
}
}

View File

@@ -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}

View File

@@ -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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}
}

View File

@@ -1,4 +1,4 @@
import Aura from '@primeng/themes/aura';
import Aura from '@primeuix/themes/aura';
type ColorPalette = Record<string, string>;

View File

@@ -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<string, string>;
@@ -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 });
}
}

View File

@@ -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<string, string>;
@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<void> {
if (this.preloadCache$) {
return this.preloadCache$;
@@ -47,12 +57,12 @@ export class IconService {
this.preloadCache$ = this.http.get<IconContentMap>(`${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<SvgIconBatchResponse> {
return this.http.post<SvgIconBatchResponse>(`${this.baseUrl}/batch`, {icons}).pipe(
return this.http.post<SvgIconBatchResponse>(`${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);
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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