Merge branch 'V2' into add_eslint_plugins_20250928

This commit is contained in:
Ludy 2025-11-06 17:42:03 +01:00 committed by GitHub
commit 7f3870fad7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1103 additions and 228 deletions

View File

@ -87,7 +87,7 @@ jobs:
fi
fi
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96")
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
should=true

View File

@ -49,4 +49,33 @@ public class RequestUriUtils {
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
}
/**
* Checks if the request URI is a public authentication endpoint that doesn't require
* authentication. This includes login, signup, OAuth callbacks, and public config endpoints.
*
* @param requestURI The full request URI
* @param contextPath The servlet context path
* @return true if the endpoint is public and doesn't require authentication
*/
public static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.contains("/v1/api-docs");
}
}

View File

@ -17,6 +17,20 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
String contextPath = request.getContextPath();
String requestURI = request.getRequestURI();
// For API requests, return JSON error
if (requestURI.startsWith(contextPath + "/api/")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
String message =
authException != null ? authException.getMessage() : "Authentication required";
response.getWriter().write("{\"error\":\"" + message + "\"}");
} else {
// For non-API requests, use default behavior
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
}

View File

@ -1,5 +1,6 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
@ -28,11 +29,15 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
@ -67,6 +72,7 @@ public class SecurityConfiguration {
private final boolean loginEnabledValue;
private final boolean runningProOrHigher;
private final ApplicationProperties applicationProperties;
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
@ -86,6 +92,7 @@ public class SecurityConfiguration {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
ApplicationProperties.Security securityProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
@ -102,6 +109,7 @@ public class SecurityConfiguration {
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.applicationProperties = applicationProperties;
this.securityProperties = securityProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
@ -120,7 +128,79 @@ public class SecurityConfiguration {
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
public CorsConfigurationSource corsConfigurationSource() {
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
} else {
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
log.info(
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
}
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
// Enable CORS only if we have configured origins
CorsConfigurationSource corsSource = corsConfigurationSource();
if (corsSource != null) {
http.cors(cors -> cors.configurationSource(corsSource));
} else {
// Explicitly disable CORS when no origins are configured
http.cors(cors -> cors.disable());
}
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
@ -130,12 +210,8 @@ public class SecurityConfiguration {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
if (v2Enabled) {
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
}
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
@ -195,6 +271,18 @@ public class SecurityConfiguration {
});
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
// Configure exception handling for API endpoints
http.exceptionHandling(
exceptions ->
exceptions.defaultAuthenticationEntryPointFor(
jwtAuthenticationEntryPoint,
request -> {
String contextPath = request.getContextPath();
String requestURI = request.getRequestURI();
return requestURI.startsWith(contextPath + "/api/");
}));
http.logout(
logout ->
logout.logoutRequestMatcher(
@ -228,43 +316,12 @@ public class SecurityConfiguration {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Remove the context path from the URI
String trimmedUri =
uri.startsWith(contextPath)
? uri.substring(
contextPath.length())
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
|| trimmedUri.startsWith("/signup")
|| trimmedUri.startsWith("/auth/callback")
|| trimmedUri.startsWith("/error")
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
|| trimmedUri.startsWith("/css/")
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith("/pdfjs/")
|| trimmedUri.startsWith("/pdfjs-legacy/")
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status")
|| trimmedUri.startsWith("/api/v1/config")
|| trimmedUri.startsWith(
"/api/v1/auth/register")
|| trimmedUri.startsWith(
"/api/v1/user/register")
|| trimmedUri.startsWith(
"/api/v1/auth/login")
|| trimmedUri.startsWith(
"/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/me")
|| trimmedUri.startsWith("/v1/api-docs")
|| uri.contains("/v1/api-docs");
// Check if it's a public auth endpoint or static
// resource
return RequestUriUtils.isStaticResource(
contextPath, uri)
|| RequestUriUtils.isPublicAuthEndpoint(
uri, contextPath);
})
.permitAll()
.anyRequest()
@ -333,8 +390,12 @@ public class SecurityConfiguration {
.saml2Login(
saml2 -> {
try {
saml2.loginPage("/saml2")
.relyingPartyRegistrationRepository(
// Only set login page for v1/Thymeleaf mode
if (!v2Enabled) {
saml2.loginPage("/saml2");
}
saml2.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))

View File

@ -21,11 +21,15 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */
@ -39,6 +43,7 @@ public class AuthController {
private final UserService userService;
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService;
/**
* Login endpoint - replaces Supabase signInWithPassword
@ -49,8 +54,11 @@ public class AuthController {
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/login")
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public ResponseEntity<?> login(
@RequestBody UsernameAndPass request, HttpServletResponse response) {
@RequestBody UsernameAndPass request,
HttpServletRequest httpRequest,
HttpServletResponse response) {
try {
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
@ -67,20 +75,30 @@ public class AuthController {
.body(Map.of("error", "Password is required"));
}
log.debug("Login attempt for user: {}", request.getUsername());
String username = request.getUsername().trim();
String ip = httpRequest.getRemoteAddr();
UserDetails userDetails =
userDetailsService.loadUserByUsername(request.getUsername().trim());
// Check if account is blocked due to too many failed attempts
if (loginAttemptService.isBlocked(username)) {
log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Account is locked due to too many failed attempts"));
}
log.debug("Login attempt for user: {} from IP: {}", username, ip);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
if (!userService.isPasswordCorrect(user, request.getPassword())) {
log.warn("Invalid password for user: {}", request.getUsername());
log.warn("Invalid password for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
}
if (!user.isEnabled()) {
log.warn("Disabled user attempted login: {}", request.getUsername());
log.warn("Disabled user attempted login: {} from IP: {}", username, ip);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
}
@ -91,7 +109,9 @@ public class AuthController {
String token = jwtService.generateToken(user.getUsername(), claims);
log.info("Login successful for user: {}", request.getUsername());
// Record successful login
loginAttemptService.loginSucceeded(username);
log.info("Login successful for user: {} from IP: {}", username, ip);
return ResponseEntity.ok(
Map.of(
@ -99,11 +119,15 @@ public class AuthController {
"session", Map.of("access_token", token, "expires_in", 3600)));
} catch (UsernameNotFoundException e) {
log.warn("User not found: {}", request.getUsername());
String username = request.getUsername();
log.warn("User not found: {}", username);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid username or password"));
} catch (AuthenticationException e) {
log.error("Authentication failed for user: {}", request.getUsername(), e);
String username = request.getUsername();
log.error("Authentication failed for user: {}", username, e);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
} catch (Exception e) {
@ -228,11 +252,4 @@ public class AuthController {
return userMap;
}
// ===========================
// Request/Response DTOs
// ===========================
/** Login request DTO */
public record LoginRequest(String email, String password) {}
}

View File

@ -1,5 +1,6 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
@ -80,17 +81,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Public auth endpoints that don't require JWT
boolean isPublicAuthEndpoint =
requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/signup")
|| requestURI.startsWith(contextPath + "/auth/")
|| requestURI.startsWith(contextPath + "/oauth2")
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh");
if (!isPublicAuthEndpoint) {
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")

View File

@ -1,5 +1,7 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@ -105,11 +107,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// If we still don't have any authentication, deny the request
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
// Allow public auth endpoints to pass through without authentication
if (isPublicAuthEndpoint(requestURI, contextPath)) {
filterChain.doFilter(request, response);
return;
}
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
} else {
@ -200,6 +208,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout");
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@ -225,7 +250,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String contextPath = request.getContextPath();
String[] permitAllPatterns = {
contextPath + "/login",
contextPath + "/signup",
contextPath + "/register",
contextPath + "/error",
contextPath + "/images/",
@ -237,7 +261,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/pdfjs-legacy/",
contextPath + "/api/v1/info/status",
contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/register",
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/site.webmanifest"

View File

@ -4,9 +4,14 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
@ -16,6 +21,7 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@ -37,6 +43,9 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
@ -119,7 +128,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Build context-aware redirect URL based on the original request
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
String redirectUrl =
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
@ -149,30 +159,110 @@ public class CustomOAuth2AuthenticationSuccessHandler
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param response HTTP response (used to clear redirect cookies)
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
HttpServletRequest request,
HttpServletResponse response,
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
}
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
if (!value.isEmpty()) {
return Optional.of(value);
}
}
}
return Optional.empty();
}
private String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String refererHost = refererUrl.getHost().toLowerCase();
if (!isOAuthProviderDomain(refererHost)) {
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
// Fall back to other methods if referer is malformed
return Optional.of(origin);
}
} catch (java.net.MalformedURLException e) {
// ignore and fall back
}
}
return Optional.empty();
}
// Fall back to building from request host/port
private String buildOriginFromRequest(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
@ -180,12 +270,50 @@ public class CustomOAuth2AuthenticationSuccessHandler
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString() + "/auth/callback#access_token=" + jwt;
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
.path("/")
.sameSite("Lax")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
/**
* Checks if the given hostname belongs to a known OAuth provider.
*
* @param hostname The hostname to check
* @return true if it's an OAuth provider domain, false otherwise
*/
private boolean isOAuthProviderDomain(String hostname) {
return hostname.contains("google.com")
|| hostname.contains("googleapis.com")
|| hostname.contains("github.com")
|| hostname.contains("microsoft.com")
|| hostname.contains("microsoftonline.com")
|| hostname.contains("linkedin.com")
|| hostname.contains("apple.com");
}
}

View File

@ -165,12 +165,7 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info(
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
isValid,
githubClient.getClientId(),
githubClient.getClientSecret() != null ? "***" : "null",
githubClient.getScopes());
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(

View File

@ -4,15 +4,21 @@ import static stirling.software.proprietary.security.model.AuthenticationType.SA
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@ -36,6 +42,9 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomSaml2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
private LoginAttemptService loginAttemptService;
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
@ -148,7 +157,7 @@ public class CustomSaml2AuthenticationSuccessHandler
// Build context-aware redirect URL based on the original request
String redirectUrl =
buildContextAwareRedirectUrl(request, contextPath, jwt);
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
@ -177,8 +186,81 @@ public class CustomSaml2AuthenticationSuccessHandler
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
HttpServletRequest request,
HttpServletResponse response,
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
}
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
if (!value.isEmpty()) {
return Optional.of(value);
}
}
}
return Optional.empty();
}
private String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
@ -189,14 +271,16 @@ public class CustomSaml2AuthenticationSuccessHandler
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
return Optional.of(origin);
} catch (java.net.MalformedURLException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
}
return Optional.empty();
}
// Fall back to building from request host/port
private String buildOriginFromRequest(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
@ -204,12 +288,34 @@ public class CustomSaml2AuthenticationSuccessHandler
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin + "/auth/callback#access_token=" + jwt;
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
.path("/")
.sameSite("Lax")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
}

View File

@ -29,6 +29,8 @@ class JwtAuthenticationEntryPointTest {
@Test
void testCommence() throws IOException {
String errorMessage = "Authentication failed";
when(request.getRequestURI()).thenReturn("/redact");
when(authException.getMessage()).thenReturn(errorMessage);
jwtAuthenticationEntryPoint.commence(request, response, authException);

View File

@ -599,6 +599,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -642,6 +643,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -665,6 +667,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@ -748,6 +751,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -764,6 +768,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -781,6 +786,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -817,6 +823,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -851,6 +858,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -887,6 +895,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -962,6 +971,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -1117,6 +1127,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1160,6 +1171,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2190,6 +2202,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2240,6 +2253,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2307,6 +2321,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@ -3993,6 +4008,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4323,6 +4339,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4333,6 +4350,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4393,6 +4411,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@ -5106,7 +5125,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
@ -5116,7 +5134,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
@ -5127,7 +5144,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
@ -5140,7 +5156,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
@ -5167,6 +5182,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5889,6 +5905,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -6955,7 +6972,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7378,6 +7396,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7548,6 +7567,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -8988,6 +9008,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9812,6 +9833,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@ -11619,6 +11641,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11898,6 +11921,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -12270,6 +12294,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -12279,6 +12304,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13982,6 +14008,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14296,6 +14323,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14378,6 +14406,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14582,6 +14611,7 @@
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14733,6 +14763,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14746,6 +14777,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",

View File

@ -1418,6 +1418,26 @@
},
"submit": "Remove Pages"
},
"extractPages": {
"title": "Extract Pages",
"pageNumbers": {
"label": "Pages to Extract",
"placeholder": "e.g., 1,3,5-8 or odd & 1-10"
},
"settings": {
"title": "Settings"
},
"tooltip": {
"description": "Extracts the selected pages into a new PDF, preserving order."
},
"error": {
"failed": "Failed to extract pages"
},
"results": {
"title": "Pages Extracted"
},
"submit": "Extract Pages"
},
"pageSelection": {
"tooltip": {
"header": {
@ -1494,6 +1514,7 @@
}
},
"bulkSelection": {
"syntaxError": "There is a syntax issue. See Page Selection tips for help.",
"header": {
"title": "Page Selection Guide"
},
@ -2975,7 +2996,8 @@
"options": {
"highContrast": "High contrast",
"invertAll": "Invert all colours",
"custom": "Custom"
"custom": "Custom",
"cmyk": "Convert to CMYK"
},
"tooltip": {
"header": {
@ -3002,6 +3024,10 @@
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
"bullet1": "Text colour - Choose the colour for text elements",
"bullet2": "Background colour - Set the background colour for the document"
},
"cmyk": {
"title": "Convert to CMYK",
"text": "Convert the PDF from RGB colour space to CMYK colour space, optimized for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers."
}
},
"error": {

View File

@ -893,6 +893,26 @@
},
"submit": "Remove Pages"
},
"extractPages": {
"title": "Extract Pages",
"pageNumbers": {
"label": "Pages to Extract",
"placeholder": "e.g., 1,3,5-8 or odd & 1-10"
},
"settings": {
"title": "Settings"
},
"tooltip": {
"description": "Extracts the selected pages into a new PDF, preserving order."
},
"error": {
"failed": "Failed to extract pages"
},
"results": {
"title": "Pages Extracted"
},
"submit": "Extract Pages"
},
"pageSelection": {
"tooltip": {
"header": {
@ -958,6 +978,7 @@
}
},
"bulkSelection": {
"syntaxError": "There is a syntax issue. See Page Selection tips for help.",
"header": { "title": "Page Selection Guide" },
"syntax": {
"title": "Syntax Basics",

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint';
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
interface BulkSelectionPanelProps {
@ -20,26 +20,9 @@ const BulkSelectionPanel = ({
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const maxPages = displayDocument?.pages?.length ?? 0;
// Validate input syntax and show lightweight feedback
useEffect(() => {
const text = (csvInput || '').trim();
if (!text) {
setSyntaxError(null);
return;
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
} catch {
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
}
}, [csvInput, maxPages]);
const handleClear = () => {
setCsvInput('');
onUpdatePagesFromCSV('');
@ -56,10 +39,12 @@ const BulkSelectionPanel = ({
onToggleAdvanced={setAdvancedOpened}
/>
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={syntaxError}
syntaxError={null}
/>
<AdvancedSelectionPanel

View File

@ -10,7 +10,7 @@ import {
everyNthExpression,
rangeExpression,
LogicalOperator,
} from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
} from '@app/utils/bulkselection/selectionBuilders';
import SelectPages from '@app/components/pageEditor/bulkSelectionPanel/SelectPages';
import OperatorsSection from '@app/components/pageEditor/bulkSelectionPanel/OperatorsSection';

View File

@ -252,6 +252,25 @@
color: var(--text-brand-accent);
}
/* Compact error container for inline tool settings */
.errorCompact {
background-color: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
max-width: 100%;
}
/* Two-line clamp for compact error text */
.errorTextClamp {
color: var(--text-brand-accent);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark-mode adjustments */
:global([data-mantine-color-scheme='dark']) .selectedList {
background-color: var(--bg-raised);

View File

@ -1,7 +1,7 @@
import { Button, Text, Group, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { LogicalOperator } from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
import { LogicalOperator } from '@app/utils/bulkselection/selectionBuilders';
interface OperatorsSectionProps {
csvInput: string;

View File

@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@mantine/core';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
interface PageSelectionSyntaxHintProps {
input: string;
/** Optional known page count; if not provided, a large max is used for syntax-only checks */
maxPages?: number;
/** panel = full bulk panel style, compact = inline tool style */
variant?: 'panel' | 'compact';
}
const FALLBACK_MAX_PAGES = 100000; // large upper bound for syntax validation without a document
const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSelectionSyntaxHintProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const { t } = useTranslation();
useEffect(() => {
const text = (input || '').trim();
if (!text) {
setSyntaxError(null);
return;
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES);
setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null);
} catch {
setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.'));
}
}, [input, maxPages]);
if (!syntaxError) return null;
return (
<div className={variant === 'panel' ? classes.selectedList : classes.errorCompact}>
<Text size="xs" className={variant === 'panel' ? classes.errorText : classes.errorTextClamp}>{syntaxError}</Text>
</div>
);
};
export default PageSelectionSyntaxHint;

View File

@ -0,0 +1,36 @@
import { Stack, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters";
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
interface ExtractPagesSettingsProps {
parameters: ExtractPagesParameters;
onParameterChange: <K extends keyof ExtractPagesParameters>(key: K, value: ExtractPagesParameters[K]) => void;
disabled?: boolean;
}
const ExtractPagesSettings = ({ parameters, onParameterChange, disabled = false }: ExtractPagesSettingsProps) => {
const { t } = useTranslation();
const handleChange = (value: string) => {
onParameterChange('pageNumbers', value);
};
return (
<Stack gap="md">
<TextInput
label={t('extractPages.pageNumbers.label', 'Pages to Extract')}
value={parameters.pageNumbers || ''}
onChange={(event) => handleChange(event.currentTarget.value)}
placeholder={t('extractPages.pageNumbers.placeholder', 'e.g., 1,3,5-8 or odd & 1-10')}
disabled={disabled}
required
/>
<PageSelectionSyntaxHint input={parameters.pageNumbers || ''} variant="compact" />
</Stack>
);
};
export default ExtractPagesSettings;

View File

@ -23,6 +23,10 @@ const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false
{
value: 'CUSTOM_COLOR',
label: t('replaceColor.options.custom', 'Custom')
},
{
value: 'COLOR_SPACE_CONVERSION',
label: t('replaceColor.options.cmyk', 'Convert to CMYK')
}
];

View File

@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
import { usePageSelectionTips } from '@app/components/tooltips/usePageSelectionTips';
export const useExtractPagesTips = (): TooltipContent => {
const { t } = useTranslation();
const base = usePageSelectionTips();
return {
header: base.header,
tips: [
{
description: t('extractPages.tooltip.description', 'Extracts the selected pages into a new PDF, preserving order.')
},
...(base.tips || [])
]
};
};
export default useExtractPagesTips;

View File

@ -34,6 +34,10 @@ export const useReplaceColorTips = (): TooltipContent => {
t("replaceColor.tooltip.custom.bullet1", "Text colour - Choose the colour for text elements"),
t("replaceColor.tooltip.custom.bullet2", "Background colour - Set the background colour for the document")
]
},
{
title: t("replaceColor.tooltip.cmyk.title", "Convert to CMYK"),
description: t("replaceColor.tooltip.cmyk.text", "Convert the PDF from RGB colour space to CMYK colour space, optimized for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers.")
}
]
};

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import apiClient from '@app/services/apiClient';
export interface AppConfig {
baseUrl?: string;
@ -36,52 +36,84 @@ interface AppConfigContextValue {
refetch: () => Promise<void>;
}
// Create context
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
const AppConfigContext = createContext<AppConfigContextValue | undefined>({
config: null,
loading: true,
error: null,
refetch: async () => {},
});
/**
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const [fetchCount, setFetchCount] = useState(0);
const fetchConfig = async (force = false) => {
// Prevent duplicate fetches unless forced
if (!force && fetchCount > 0) {
console.debug('[AppConfig] Already fetched, skipping');
return;
}
const fetchConfig = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/config/app-config', {
headers,
});
// apiClient automatically adds JWT header if available via interceptors
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
const data = response.data;
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
console.debug('[AppConfig] Config fetched successfully:', data);
setConfig(data);
setFetchCount(prev => prev + 1);
} catch (err: any) {
// On 401 (not authenticated), use default config with login enabled
// This allows the app to work even without authentication
if (err.response?.status === 401) {
console.debug('[AppConfig] 401 error - using default config (login enabled)');
setConfig({ enableLogin: true });
setLoading(false);
return;
}
const data: AppConfig = await response.json();
setConfig(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[AppConfig] Failed to fetch app config:', err);
// On error, assume login is enabled (safe default)
setConfig({ enableLogin: true });
} finally {
setLoading(false);
}
};
useEffect(() => {
// Always try to fetch config to check if login is disabled
// The endpoint should be public and return proper JSON
fetchConfig();
}, []);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
// Force refetch with JWT
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, []);
const value: AppConfigContextValue = {
config,
loading,
error,
refetch: fetchConfig,
refetch: () => fetchConfig(true),
};
return (
@ -104,4 +136,3 @@ export function useAppConfig(): AppConfigContextValue {
return context;
}

View File

@ -79,6 +79,7 @@ import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOver
import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
import CompressSettings from "@app/components/tools/compress/CompressSettings";
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
@ -105,7 +106,9 @@ import AddPageNumbers from "@app/tools/AddPageNumbers";
import RemoveAnnotations from "@app/tools/RemoveAnnotations";
import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings";
import ExtractImages from "@app/tools/ExtractImages";
import ExtractPages from "@app/tools/ExtractPages";
import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings";
import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings";
import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings";
import AddStampAutomationSettings from "@app/components/tools/addStamp/AddStampAutomationSettings";
import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings";
@ -474,12 +477,14 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
extractPages: {
icon: <LocalIcon icon="upload-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.extractPages.title", "Extract Pages"),
component: null,
component: ExtractPages,
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION,
synonyms: getSynonyms(t, "extractPages"),
automationSettings: null,
automationSettings: ExtractPagesSettings,
operationConfig: extractPagesOperationConfig,
endpoints: ["rearrange-pages"],
},
extractImages: {
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -0,0 +1,59 @@
import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ExtractPagesParameters, defaultParameters } from '@app/hooks/tools/extractPages/useExtractPagesParameters';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { parseSelection } from '@app/utils/bulkselection/parseSelection';
// Convert advanced page selection expression into CSV of explicit one-based page numbers
async function resolveSelectionToCsv(expression: string, file: File): Promise<string> {
// Load PDF to determine max pages
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { disableAutoFetch: true, disableStream: true });
try {
const maxPages = pdf.numPages;
const pages = parseSelection(expression || '', maxPages);
return pages.join(',');
} finally {
pdfWorkerManager.destroyDocument(pdf);
}
}
export const extractPagesOperationConfig = {
toolType: ToolType.custom,
operationType: 'extractPages',
customProcessor: async (parameters: ExtractPagesParameters, files: File[]): Promise<File[]> => {
const outputs: File[] = [];
for (const file of files) {
// Resolve selection into CSV acceptable by backend
const csv = await resolveSelectionToCsv(parameters.pageNumbers, file);
const formData = new FormData();
formData.append('fileInput', file);
formData.append('pageNumbers', csv);
const response = await apiClient.post('/api/v1/general/rearrange-pages', formData, { responseType: 'blob' });
// Name output file with suffix
const base = (file.name || 'document.pdf').replace(/\.[^.]+$/, '');
const outName = `${base}_extracted_pages.pdf`;
const outFile = new File([response.data], outName, { type: 'application/pdf' });
outputs.push(outFile);
}
return outputs;
},
defaultParameters,
} as const;
export const useExtractPagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<ExtractPagesParameters>({
...extractPagesOperationConfig,
getErrorMessage: createStandardErrorHandler(t('extractPages.error.failed', 'Failed to extract pages'))
});
};

View File

@ -0,0 +1,22 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ExtractPagesParameters extends BaseParameters {
pageNumbers: string;
}
export const defaultParameters: ExtractPagesParameters = {
pageNumbers: '',
};
export type ExtractPagesParametersHook = BaseParametersHook<ExtractPagesParameters>;
export const useExtractPagesParameters = (): ExtractPagesParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'rearrange-pages',
validateFn: (p) => (p.pageNumbers || '').trim().length > 0,
});
};

View File

@ -2,7 +2,7 @@ import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ReplaceColorParameters extends BaseParameters {
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION';
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION' | 'COLOR_SPACE_CONVERSION';
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK';
textColor: string;
backGroundColor: string;

View File

@ -1,8 +1,13 @@
import { useState, useEffect } from 'react';
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
import apiClient from '@app/services/apiClient';
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
const globalEndpointCache: Record<string, boolean> = {};
/**
* Hook to check if a specific endpoint is enabled
* This wraps the context for single endpoint checks
*/
export function useEndpointEnabled(endpoint: string): {
enabled: boolean | null;
@ -13,7 +18,6 @@ export function useEndpointEnabled(endpoint: string): {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const fetchEndpointStatus = async () => {
if (!endpoint) {
@ -26,15 +30,8 @@ export function useEndpointEnabled(endpoint: string): {
setLoading(true);
setError(null);
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
headers,
});
if (!response.ok) {
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
}
const isEnabled: boolean = await response.json();
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
const isEnabled = response.data;
setEnabled(isEnabled);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
@ -69,43 +66,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const fetchAllEndpointStatuses = async () => {
const fetchAllEndpointStatuses = async (force = false) => {
const endpointsKey = [...endpoints].sort().join(',');
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => {
if (endpoint in globalEndpointCache) {
acc[endpoint] = globalEndpointCache[endpoint];
}
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(cachedStatus);
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
}
// Check if JWT exists - if not, optimistically enable all endpoints
const hasJwt = !!localStorage.getItem('stirling_jwt');
if (!hasJwt) {
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// Use batch API for efficiency
const endpointsParam = endpoints.join(',');
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, {
headers,
});
if (!response.ok) {
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
}
const statusMap: Record<string, boolean> = await response.json();
setEndpointStatus(statusMap);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are disabled on error
const fallbackStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = false;
// Check which endpoints we haven't fetched yet
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint];
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(fallbackStatus);
setEndpointStatus(cachedStatus);
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
}
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
const statusMap = response.data;
// Update global cache with new results
Object.assign(globalEndpointCache, statusMap);
// Get all requested endpoints from cache (including previously cached ones)
const fullStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(fullStatus);
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
globalEndpointCache[endpoint] = true; // Cache the optimistic value
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
setLoading(false);
return;
}
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
} finally {
setLoading(false);
}
@ -115,10 +170,24 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
fetchAllEndpointStatuses();
}, [endpoints.join(',')]); // Re-run when endpoints array changes
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
// Clear the global cache to allow refetch with JWT
globalFetchedSets.clear();
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
fetchAllEndpointStatuses(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [endpoints.join(',')]);
return {
endpointStatus,
loading,
error,
refetch: fetchAllEndpointStatuses,
refetch: () => fetchAllEndpointStatuses(true),
};
}

View File

@ -24,10 +24,18 @@ export const useToolManagement = (): ToolManagementResult => {
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
// Tools without endpoints are always available
if (endpoints.length === 0) return true;
// Check if at least one endpoint is enabled
// If endpoint is not in status map, assume enabled (optimistic fallback)
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {

View File

@ -0,0 +1,62 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { useExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters";
import { useExtractPagesOperation } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings";
import useExtractPagesTips from "@app/components/tooltips/useExtractPagesTips";
const ExtractPages = (props: BaseToolProps) => {
const { t } = useTranslation();
const tooltipContent = useExtractPagesTips();
const base = useBaseTool(
'extract-pages',
useExtractPagesParameters,
useExtractPagesOperation,
props
);
const settingsContent = (
<ExtractPagesSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("extractPages.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: settingsContent,
tooltip: tooltipContent,
},
],
executeButton: {
text: t("extractPages.submit", "Extract Pages"),
loadingText: t("loading"),
onClick: base.handleExecute,
isVisible: !base.hasResults,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("extractPages.results.title", "Pages Extracted"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ExtractPages as ToolComponent;

View File

@ -1,4 +1,4 @@
// Pure helper utilities for the BulkSelectionPanel UI
// Pure helper utilities for building and manipulating bulk page selection expressions
export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';

View File

@ -95,23 +95,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
console.debug('[Auth] Initializing auth...');
// First check if login is enabled
const configResponse = await fetch('/api/v1/config/app-config');
if (configResponse.ok) {
const config = await configResponse.json();
// If login is disabled, skip authentication entirely
if (config.enableLogin === false) {
console.debug('[Auth] Login disabled - skipping authentication');
if (mounted) {
setSession(null);
setLoading(false);
}
return;
}
}
// Login is enabled, proceed with normal auth check
// Skip config check entirely - let the app handle login state
// The config will be fetched by useAppConfig when needed
const { data, error } = await springAuth.getSession();
if (!mounted) return;

View File

@ -1,3 +1,5 @@
import { BASE_PATH } from '@app/constants/app';
/**
* Spring Auth Client
*
@ -7,6 +9,37 @@
* - No email confirmation flow (auto-confirmed on registration)
*/
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== 'string') {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || '/';
const query = parsed.search || '';
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}
}
function persistRedirectPath(path: string): void {
try {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
} catch (error) {
console.warn('[SpringAuth] Failed to persist OAuth redirect path', error);
}
}
// Auth types
export interface User {
id: string;
@ -85,20 +118,44 @@ class SpringAuthClient {
}
// Verify with backend
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
console.debug('[SpringAuth] /me response status:', response.status);
const contentType = response.headers.get('content-type');
console.debug('[SpringAuth] /me content-type:', contentType);
if (!response.ok) {
// Log the error response for debugging
const errorBody = await response.text();
console.error('[SpringAuth] getSession: /api/v1/auth/me failed', {
status: response.status,
statusText: response.statusText,
body: errorBody
});
// Token invalid or expired - clear it
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage');
return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } };
}
// Check if response is JSON before parsing
if (!contentType?.includes('application/json')) {
const text = await response.text();
console.error('[SpringAuth] /me returned non-JSON:', {
contentType,
bodyPreview: text.substring(0, 200)
});
throw new Error(`/api/v1/auth/me returned HTML instead of JSON`);
}
const data = await response.json();
console.debug('[SpringAuth] /me response data:', data);
// Create session object
const session: Session = {
@ -151,6 +208,9 @@ class SpringAuthClient {
localStorage.setItem('stirling_jwt', token);
console.log('[SpringAuth] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
const session: Session = {
user: data.user,
access_token: token,
@ -220,6 +280,9 @@ class SpringAuthClient {
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
const redirectUrl = `/oauth2/authorization/${params.provider}`;
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
@ -299,6 +362,9 @@ class SpringAuthClient {
// Store new token
localStorage.setItem('stirling_jwt', newToken);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
// Get updated user info
const userResponse = await fetch('/api/v1/auth/me', {
headers: {

View File

@ -36,6 +36,9 @@ export default function AuthCallback() {
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'))
// Refresh session to load user info into state
await refreshSession();

View File

@ -35,16 +35,19 @@ export default defineConfig(({ mode }) => {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/login/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
},
},