Compare commits
4 Commits
feat/brand
...
b317196217
| Author | SHA1 | Date | |
|---|---|---|---|
| b317196217 | |||
| cc343ee27c | |||
| 74d1b16b7c | |||
| 653082868a |
@@ -0,0 +1,88 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AllowedOriginService {
|
||||
|
||||
private final List<String> allowedOrigins;
|
||||
|
||||
public AllowedOriginService(
|
||||
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
|
||||
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
|
||||
) {
|
||||
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
|
||||
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
|
||||
|
||||
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
|
||||
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
|
||||
}
|
||||
|
||||
if (configuredOrigins.isEmpty()) {
|
||||
throw new IllegalStateException("At least one allowed origin must be configured.");
|
||||
}
|
||||
this.allowedOrigins = List.copyOf(configuredOrigins);
|
||||
}
|
||||
|
||||
public List<String> getAllowedOrigins() {
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
public boolean isAllowed(String rawOriginOrUrl) {
|
||||
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
|
||||
}
|
||||
|
||||
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
|
||||
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||
if (normalizedOrigin == null) {
|
||||
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
|
||||
}
|
||||
configuredOrigins.add(normalizedOrigin);
|
||||
}
|
||||
|
||||
private String normalizeRequestOrigin(String rawOriginOrUrl) {
|
||||
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
URI uri = URI.create(rawOriginOrUrl.trim());
|
||||
String scheme = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
if (scheme == null || host == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||
int port = uri.getPort();
|
||||
if (isDefaultPort(normalizedScheme, port) || port < 0) {
|
||||
return normalizedScheme + "://" + normalizedHost;
|
||||
}
|
||||
return normalizedScheme + "://" + normalizedHost + ":" + port;
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDefaultPort(String scheme, int port) {
|
||||
return ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
public class CorsConfig {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
"http://localhost",
|
||||
"http://localhost:4200",
|
||||
"http://localhost:80",
|
||||
"http://127.0.0.1",
|
||||
"https://dev.3d-fab.ch",
|
||||
"https://int.3d-fab.ch",
|
||||
"https://3d-fab.ch"
|
||||
)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -18,6 +19,7 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
|
||||
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
||||
) throws Exception {
|
||||
http
|
||||
@@ -40,7 +42,8 @@ public class SecurityConfig {
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
||||
}))
|
||||
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.printcalculator.security;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class AdminCsrfProtectionFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
|
||||
|
||||
private final AllowedOriginService allowedOriginService;
|
||||
|
||||
public AdminCsrfProtectionFilter(AllowedOriginService allowedOriginService) {
|
||||
this.allowedOriginService = allowedOriginService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = resolvePath(request);
|
||||
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase(Locale.ROOT);
|
||||
return !path.startsWith("/api/admin/") || SAFE_METHODS.contains(method);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String origin = request.getHeader(HttpHeaders.ORIGIN);
|
||||
String referer = request.getHeader(HttpHeaders.REFERER);
|
||||
|
||||
if (allowedOriginService.isAllowed(origin) || allowedOriginService.isAllowed(referer)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("{\"error\":\"CSRF_INVALID\"}");
|
||||
}
|
||||
|
||||
private String resolvePath(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
|
||||
return path.substring(contextPath.length());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -399,6 +399,8 @@ public class PublicShopCatalogService {
|
||||
Map<String, String> variantColorHexByMaterialAndColor,
|
||||
String language) {
|
||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||
return new ShopProductSummaryDto(
|
||||
entry.product().getId(),
|
||||
@@ -417,7 +419,7 @@ public class PublicShopCatalogService {
|
||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||
selectPrimaryMedia(images),
|
||||
toProductModelDto(entry),
|
||||
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
|
||||
publicPathSegment,
|
||||
localizedPaths
|
||||
);
|
||||
}
|
||||
@@ -429,9 +431,10 @@ public class PublicShopCatalogService {
|
||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
||||
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||
return new ShopProductDetailDto(
|
||||
entry.product().getId(),
|
||||
return new ShopProductDetailDto(entry.product().getId(),
|
||||
entry.product().getSlug(),
|
||||
entry.product().getNameForLanguage(language),
|
||||
entry.product().getExcerptForLanguage(language),
|
||||
@@ -458,7 +461,7 @@ public class PublicShopCatalogService {
|
||||
selectPrimaryMedia(images),
|
||||
images,
|
||||
toProductModelDto(entry),
|
||||
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
|
||||
publicPathSegment,
|
||||
localizedPaths
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
|
||||
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
||||
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
||||
app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:}
|
||||
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
||||
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import com.printcalculator.config.CorsConfig;
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.controller.admin.AdminAuthController;
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminLoginThrottleService;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import com.printcalculator.security.AdminSessionService;
|
||||
@@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(controllers = AdminAuthController.class)
|
||||
@Import({
|
||||
CorsConfig.class,
|
||||
AllowedOriginService.class,
|
||||
SecurityConfig.class,
|
||||
AdminCsrfProtectionFilter.class,
|
||||
AdminSessionAuthenticationFilter.class,
|
||||
AdminSessionService.class,
|
||||
AdminLoginThrottleService.class
|
||||
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
class AdminAuthSecurityTest {
|
||||
|
||||
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.1");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.2");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.3");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.3");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.authenticated").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/auth/login")
|
||||
.with(req -> {
|
||||
req.setRemoteAddr("10.0.0.30");
|
||||
return req;
|
||||
})
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception {
|
||||
mockMvc.perform(options("/api/admin/auth/login")
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN))
|
||||
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/auth/me"))
|
||||
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.4");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.printcalculator.controller.admin;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import com.printcalculator.config.CorsConfig;
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.service.order.AdminOrderControllerService;
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminLoginThrottleService;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import com.printcalculator.security.AdminSessionService;
|
||||
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
||||
@Import({
|
||||
CorsConfig.class,
|
||||
AllowedOriginService.class,
|
||||
SecurityConfig.class,
|
||||
AdminCsrfProtectionFilter.class,
|
||||
AdminSessionAuthenticationFilter.class,
|
||||
AdminSessionService.class,
|
||||
AdminLoginThrottleService.class,
|
||||
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
class AdminOrderControllerSecurityTest {
|
||||
|
||||
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.44");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.printcalculator.controller.admin;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import com.printcalculator.config.CorsConfig;
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminLoginThrottleService;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import com.printcalculator.security.AdminSessionService;
|
||||
@@ -36,7 +39,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||
@Import({
|
||||
CorsConfig.class,
|
||||
AllowedOriginService.class,
|
||||
SecurityConfig.class,
|
||||
AdminCsrfProtectionFilter.class,
|
||||
AdminSessionAuthenticationFilter.class,
|
||||
AdminSessionService.class,
|
||||
AdminLoginThrottleService.class,
|
||||
@@ -49,6 +55,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
class AdminShopProductControllerSecurityTest {
|
||||
|
||||
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -61,11 +69,22 @@ class AdminShopProductControllerSecurityTest {
|
||||
@Test
|
||||
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void translateProduct_withAdminCookieAndMissingOrigin_shouldReturn403() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||
.cookie(loginAndExtractCookie())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||
@@ -82,6 +101,7 @@ class AdminShopProductControllerSecurityTest {
|
||||
|
||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||
.cookie(loginAndExtractCookie())
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
@@ -107,6 +127,7 @@ class AdminShopProductControllerSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.44");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.printcalculator.service.shop;
|
||||
|
||||
import com.printcalculator.dto.ShopProductCatalogResponseDto;
|
||||
import com.printcalculator.dto.ShopProductDetailDto;
|
||||
import com.printcalculator.entity.ShopCategory;
|
||||
import com.printcalculator.entity.ShopProduct;
|
||||
import com.printcalculator.entity.ShopProductVariant;
|
||||
import com.printcalculator.repository.FilamentVariantRepository;
|
||||
import com.printcalculator.repository.ShopCategoryRepository;
|
||||
import com.printcalculator.repository.ShopProductModelAssetRepository;
|
||||
import com.printcalculator.repository.ShopProductRepository;
|
||||
import com.printcalculator.repository.ShopProductVariantRepository;
|
||||
import com.printcalculator.service.media.PublicMediaQueryService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PublicShopCatalogServiceTest {
|
||||
|
||||
@Mock
|
||||
private ShopCategoryRepository shopCategoryRepository;
|
||||
@Mock
|
||||
private ShopProductRepository shopProductRepository;
|
||||
@Mock
|
||||
private ShopProductVariantRepository shopProductVariantRepository;
|
||||
@Mock
|
||||
private ShopProductModelAssetRepository shopProductModelAssetRepository;
|
||||
@Mock
|
||||
private FilamentVariantRepository filamentVariantRepository;
|
||||
@Mock
|
||||
private PublicMediaQueryService publicMediaQueryService;
|
||||
@Mock
|
||||
private ShopStorageService shopStorageService;
|
||||
|
||||
private PublicShopCatalogService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PublicShopCatalogService(
|
||||
shopCategoryRepository,
|
||||
shopProductRepository,
|
||||
shopProductVariantRepository,
|
||||
shopProductModelAssetRepository,
|
||||
filamentVariantRepository,
|
||||
publicMediaQueryService,
|
||||
shopStorageService
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProductCatalog_shouldExposePublicPathAsSegment() {
|
||||
ShopCategory category = buildCategory();
|
||||
ShopProduct product = buildProduct(category);
|
||||
ShopProductVariant variant = buildVariant(product);
|
||||
|
||||
stubPublicCatalog(category, product, variant);
|
||||
|
||||
ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en");
|
||||
|
||||
assertEquals(1, response.products().size());
|
||||
assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath());
|
||||
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en"));
|
||||
assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProduct_shouldExposePublicPathAsSegment() {
|
||||
ShopCategory category = buildCategory();
|
||||
ShopProduct product = buildProduct(category);
|
||||
ShopProductVariant variant = buildVariant(product);
|
||||
|
||||
stubPublicCatalog(category, product, variant);
|
||||
|
||||
ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en");
|
||||
|
||||
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
|
||||
}
|
||||
|
||||
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
|
||||
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
|
||||
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
|
||||
when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList()))
|
||||
.thenReturn(List.of(variant));
|
||||
when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of());
|
||||
when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of());
|
||||
when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of());
|
||||
}
|
||||
|
||||
private ShopCategory buildCategory() {
|
||||
ShopCategory category = new ShopCategory();
|
||||
category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
|
||||
category.setSlug("accessori");
|
||||
category.setName("Accessori");
|
||||
category.setNameIt("Accessori");
|
||||
category.setNameEn("Accessories");
|
||||
category.setIsActive(true);
|
||||
category.setSortOrder(0);
|
||||
return category;
|
||||
}
|
||||
|
||||
private ShopProduct buildProduct(ShopCategory category) {
|
||||
ShopProduct product = new ShopProduct();
|
||||
product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab"));
|
||||
product.setCategory(category);
|
||||
product.setSlug("bike-wall-hanger");
|
||||
product.setName("Bike Wall-Hanger");
|
||||
product.setNameIt("Supporto bici");
|
||||
product.setNameEn("Bike Wall-Hanger");
|
||||
product.setIsActive(true);
|
||||
product.setIsFeatured(true);
|
||||
product.setSortOrder(0);
|
||||
return product;
|
||||
}
|
||||
|
||||
private ShopProductVariant buildVariant(ShopProduct product) {
|
||||
ShopProductVariant variant = new ShopProductVariant();
|
||||
variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
variant.setProduct(product);
|
||||
variant.setVariantLabel("PLA");
|
||||
variant.setColorName("Grigio");
|
||||
variant.setInternalMaterialCode("PLA");
|
||||
variant.setPriceChf(new BigDecimal("29.90"));
|
||||
variant.setIsActive(true);
|
||||
variant.setIsDefault(true);
|
||||
variant.setSortOrder(0);
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
@@ -613,11 +613,11 @@
|
||||
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
||||
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
||||
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||
"HERO_SWISS_TITLE": "Mit Sitz in der Schweiz",
|
||||
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
||||
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||
"HERO_SWISS_LOCATION_2": "Zürich",
|
||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||
"BTN_CALCULATE": "Angebot berechnen",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
||||
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
||||
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||
"HERO_SWISS_TITLE": "Basés en Suisse",
|
||||
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
||||
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||
|
||||
@@ -84,11 +84,11 @@
|
||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
||||
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
||||
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||
"HERO_SWISS_TITLE": "Con sede in Svizzera",
|
||||
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
||||
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||
"HERO_SWISS_LOCATION_2": "Zurigo",
|
||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||
"BTN_CALCULATE": "Calcola Preventivo",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<html lang="it-CH">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>3D fab | Stampa 3D su misura</title>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolvePublicRedirectTarget } from './server-routing';
|
||||
|
||||
describe('server routing redirects', () => {
|
||||
it('does not force a fixed-language redirect for the root path', () => {
|
||||
it('does not handle the root path because it is resolved separately', () => {
|
||||
expect(resolvePublicRedirectTarget('/')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,15 +42,22 @@ app.get(
|
||||
);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
const acceptLanguage = req.get('accept-language');
|
||||
const preferredLanguages = parseAcceptLanguage(acceptLanguage);
|
||||
const userAgent = req.get('user-agent');
|
||||
const preferredLanguages = parseAcceptLanguage(req.get('accept-language'));
|
||||
const lang = resolveInitialLanguage({
|
||||
preferredLanguages,
|
||||
});
|
||||
const stableRedirect = shouldUseStableRootRedirect(
|
||||
userAgent,
|
||||
preferredLanguages,
|
||||
);
|
||||
|
||||
res.setHeader('Vary', 'Accept-Language');
|
||||
res.setHeader('Vary', 'Accept-Language, User-Agent');
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.redirect(302, `/${lang}${querySuffix(req.originalUrl)}`);
|
||||
res.redirect(
|
||||
stableRedirect ? 308 : 302,
|
||||
`/${stableRedirect ? 'it' : lang}${querySuffix(req.originalUrl)}`,
|
||||
);
|
||||
});
|
||||
|
||||
app.get('**', (req, res, next) => {
|
||||
@@ -99,3 +106,21 @@ function querySuffix(url: string): string {
|
||||
const queryIndex = String(url ?? '').indexOf('?');
|
||||
return queryIndex >= 0 ? String(url).slice(queryIndex) : '';
|
||||
}
|
||||
|
||||
function shouldUseStableRootRedirect(
|
||||
userAgent: string | undefined,
|
||||
preferredLanguages: readonly string[],
|
||||
): boolean {
|
||||
return preferredLanguages.length === 0 || isLikelyCrawler(userAgent);
|
||||
}
|
||||
|
||||
function isLikelyCrawler(userAgent: string | undefined): boolean {
|
||||
const normalized = String(userAgent ?? '').toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /(bot|crawler|spider|slurp|bingpreview|google-read-aloud)/.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user