fix(back-end): fix load product
This commit is contained in:
@@ -1,27 +1,27 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig {
|
||||||
|
|
||||||
@Override
|
@Bean
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
|
||||||
registry.addMapping("/**")
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
.allowedOrigins(
|
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
|
||||||
"http://localhost",
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
"http://localhost:4200",
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
"http://localhost:80",
|
configuration.setAllowCredentials(true);
|
||||||
"http://127.0.0.1",
|
configuration.setMaxAge(3600L);
|
||||||
"https://dev.3d-fab.ch",
|
|
||||||
"https://int.3d-fab.ch",
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
"https://3d-fab.ch"
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
)
|
return source;
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
|
||||||
.allowedHeaders("*")
|
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -18,6 +19,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(
|
public SecurityFilterChain securityFilterChain(
|
||||||
HttpSecurity http,
|
HttpSecurity http,
|
||||||
|
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
|
||||||
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -40,7 +42,8 @@ public class SecurityConfig {
|
|||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
||||||
}))
|
}))
|
||||||
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,8 @@ public class PublicShopCatalogService {
|
|||||||
Map<String, String> variantColorHexByMaterialAndColor,
|
Map<String, String> variantColorHexByMaterialAndColor,
|
||||||
String language) {
|
String language) {
|
||||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
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());
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
return new ShopProductSummaryDto(
|
return new ShopProductSummaryDto(
|
||||||
entry.product().getId(),
|
entry.product().getId(),
|
||||||
@@ -417,7 +419,7 @@ public class PublicShopCatalogService {
|
|||||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
toProductModelDto(entry),
|
toProductModelDto(entry),
|
||||||
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
|
publicPathSegment,
|
||||||
localizedPaths
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -429,9 +431,10 @@ public class PublicShopCatalogService {
|
|||||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||||
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
||||||
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(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());
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
return new ShopProductDetailDto(
|
return new ShopProductDetailDto(entry.product().getId(),
|
||||||
entry.product().getId(),
|
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
entry.product().getNameForLanguage(language),
|
entry.product().getNameForLanguage(language),
|
||||||
entry.product().getExcerptForLanguage(language),
|
entry.product().getExcerptForLanguage(language),
|
||||||
@@ -458,7 +461,7 @@ public class PublicShopCatalogService {
|
|||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
images,
|
images,
|
||||||
toProductModelDto(entry),
|
toProductModelDto(entry),
|
||||||
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
|
publicPathSegment,
|
||||||
localizedPaths
|
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.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.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
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}
|
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
||||||
openai.translation.api-key=${OPENAI_API_KEY:}
|
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||||
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.controller.admin.AdminAuthController;
|
import com.printcalculator.controller.admin.AdminAuthController;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
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.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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.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.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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(controllers = AdminAuthController.class)
|
@WebMvcTest(controllers = AdminAuthController.class)
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class
|
AdminLoginThrottleService.class
|
||||||
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminAuthSecurityTest {
|
class AdminAuthSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.1");
|
req.setRemoteAddr("10.0.0.1");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.2");
|
req.setRemoteAddr("10.0.0.2");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isTooManyRequests())
|
.andExpect(status().isTooManyRequests())
|
||||||
.andExpect(jsonPath("$.authenticated").value(false));
|
.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
|
@Test
|
||||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/auth/me"))
|
mockMvc.perform(get("/api/admin/auth/me"))
|
||||||
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.4");
|
req.setRemoteAddr("10.0.0.4");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.service.order.AdminOrderControllerService;
|
import com.printcalculator.service.order.AdminOrderControllerService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class,
|
AdminLoginThrottleService.class,
|
||||||
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminOrderControllerSecurityTest {
|
class AdminOrderControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.44");
|
req.setRemoteAddr("10.0.0.44");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -36,7 +39,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class,
|
AdminLoginThrottleService.class,
|
||||||
@@ -49,6 +55,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminShopProductControllerSecurityTest {
|
class AdminShopProductControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -61,11 +69,22 @@ class AdminShopProductControllerSecurityTest {
|
|||||||
@Test
|
@Test
|
||||||
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||||
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
@@ -82,6 +101,7 @@ class AdminShopProductControllerSecurityTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
.cookie(loginAndExtractCookie())
|
.cookie(loginAndExtractCookie())
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{
|
{
|
||||||
@@ -107,6 +127,7 @@ class AdminShopProductControllerSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.44");
|
req.setRemoteAddr("10.0.0.44");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,6 @@ import { serverOriginInterceptor } from './core/interceptors/server-origin.inter
|
|||||||
import { catchError, firstValueFrom, of } from 'rxjs';
|
import { catchError, firstValueFrom, of } from 'rxjs';
|
||||||
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
|
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
|
||||||
import {
|
import {
|
||||||
getNavigatorLanguagePreferences,
|
|
||||||
parseAcceptLanguage,
|
|
||||||
resolveInitialLanguage,
|
resolveInitialLanguage,
|
||||||
SUPPORTED_LANGS,
|
SUPPORTED_LANGS,
|
||||||
} from './core/i18n/language-resolution';
|
} from './core/i18n/language-resolution';
|
||||||
@@ -72,11 +70,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
(typeof request?.url === 'string' && request.url) || router.url || '/';
|
(typeof request?.url === 'string' && request.url) || router.url || '/';
|
||||||
const lang = resolveInitialLanguage({
|
const lang = resolveInitialLanguage({
|
||||||
url: requestedUrl,
|
url: requestedUrl,
|
||||||
preferredLanguages: request
|
|
||||||
? parseAcceptLanguage(readRequestHeader(request, 'accept-language'))
|
|
||||||
: getNavigatorLanguagePreferences(
|
|
||||||
typeof navigator === 'undefined' ? null : navigator,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
@@ -95,21 +88,3 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideClientHydration(withEventReplay()),
|
provideClientHydration(withEventReplay()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function readRequestHeader(
|
|
||||||
request: {
|
|
||||||
headers?: Record<string, string | string[] | undefined>;
|
|
||||||
} | null,
|
|
||||||
headerName: string,
|
|
||||||
): string | null {
|
|
||||||
if (!request?.headers) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerValue = request.headers[headerName.toLowerCase()];
|
|
||||||
if (Array.isArray(headerValue)) {
|
|
||||||
return headerValue[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof headerValue === 'string' ? headerValue : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { LanguageService } from './language.service';
|
import { LanguageService } from './language.service';
|
||||||
import { RequestLike } from '../../../core/request-origin';
|
|
||||||
|
|
||||||
describe('LanguageService', () => {
|
describe('LanguageService', () => {
|
||||||
function createTranslateMock() {
|
function createTranslateMock() {
|
||||||
@@ -83,14 +82,9 @@ describe('LanguageService', () => {
|
|||||||
const translate = createTranslateMock();
|
const translate = createTranslateMock();
|
||||||
const router = createRouterMock('/calculator?session=abc');
|
const router = createRouterMock('/calculator?session=abc');
|
||||||
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
const request: RequestLike = {
|
|
||||||
headers: {
|
|
||||||
'accept-language': 'it-CH,it;q=0.9,en;q=0.8',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const service = new LanguageService(translate, router, request);
|
const service = new LanguageService(translate, router);
|
||||||
|
|
||||||
expect(translate.use).toHaveBeenCalledWith('it');
|
expect(translate.use).toHaveBeenCalledWith('it');
|
||||||
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
|
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
|
||||||
@@ -103,41 +97,31 @@ describe('LanguageService', () => {
|
|||||||
expect(navOptions.replaceUrl).toBeTrue();
|
expect(navOptions.replaceUrl).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the preferred browser language on the root URL', () => {
|
it('uses the default language on the root URL', () => {
|
||||||
const translate = createTranslateMock();
|
const translate = createTranslateMock();
|
||||||
const router = createRouterMock('/');
|
const router = createRouterMock('/');
|
||||||
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
const request: RequestLike = {
|
|
||||||
headers: {
|
|
||||||
'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const service = new LanguageService(translate, router, request);
|
const service = new LanguageService(translate, router);
|
||||||
|
|
||||||
expect(translate.use).toHaveBeenCalledWith('de');
|
expect(translate.use).toHaveBeenCalledWith('it');
|
||||||
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const firstCall = navigateSpy.calls.mostRecent();
|
const firstCall = navigateSpy.calls.mostRecent();
|
||||||
const tree = firstCall.args[0] as UrlTree;
|
const tree = firstCall.args[0] as UrlTree;
|
||||||
expect(router.serializeUrl(tree)).toBe('/de');
|
expect(router.serializeUrl(tree)).toBe('/it');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the default language for non-root URLs without a language prefix', () => {
|
it('uses the default language for non-root URLs without a language prefix', () => {
|
||||||
const translate = createTranslateMock();
|
const translate = createTranslateMock();
|
||||||
const router = createRouterMock('/calculator?session=abc');
|
const router = createRouterMock('/calculator?session=abc');
|
||||||
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
const request: RequestLike = {
|
|
||||||
headers: {
|
|
||||||
'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const service = new LanguageService(translate, router, request);
|
const service = new LanguageService(translate, router);
|
||||||
|
|
||||||
expect(translate.use).toHaveBeenCalledWith('de');
|
expect(translate.use).toHaveBeenCalledWith('it');
|
||||||
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const firstCall = navigateSpy.calls.mostRecent();
|
const firstCall = navigateSpy.calls.mostRecent();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
NavigationEnd,
|
NavigationEnd,
|
||||||
@@ -6,12 +6,7 @@ import {
|
|||||||
Router,
|
Router,
|
||||||
UrlTree,
|
UrlTree,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
import {
|
import { resolveInitialLanguage } from '../i18n/language-resolution';
|
||||||
getNavigatorLanguagePreferences,
|
|
||||||
parseAcceptLanguage,
|
|
||||||
resolveInitialLanguage,
|
|
||||||
} from '../i18n/language-resolution';
|
|
||||||
import { RequestLike } from '../../../core/request-origin';
|
|
||||||
|
|
||||||
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
|
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
|
||||||
@@ -28,7 +23,6 @@ export class LanguageService {
|
|||||||
constructor(
|
constructor(
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
|
|
||||||
) {
|
) {
|
||||||
this.translate.addLangs(this.supportedLangs);
|
this.translate.addLangs(this.supportedLangs);
|
||||||
this.translate.setFallbackLang('it');
|
this.translate.setFallbackLang('it');
|
||||||
@@ -43,11 +37,6 @@ export class LanguageService {
|
|||||||
const initialTree = this.router.parseUrl(this.router.url);
|
const initialTree = this.router.parseUrl(this.router.url);
|
||||||
const initialLang = resolveInitialLanguage({
|
const initialLang = resolveInitialLanguage({
|
||||||
url: this.router.url,
|
url: this.router.url,
|
||||||
preferredLanguages: this.request
|
|
||||||
? parseAcceptLanguage(this.readRequestHeader('accept-language'))
|
|
||||||
: getNavigatorLanguagePreferences(
|
|
||||||
typeof navigator === 'undefined' ? null : navigator,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
this.applyLanguage(initialLang);
|
this.applyLanguage(initialLang);
|
||||||
this.ensureLanguageInPath(initialTree);
|
this.ensureLanguageInPath(initialTree);
|
||||||
@@ -148,7 +137,7 @@ export class LanguageService {
|
|||||||
const queryLang = this.getQueryLang(urlTree);
|
const queryLang = this.getQueryLang(urlTree);
|
||||||
const rootLang = this.isSupportedLang(queryLang)
|
const rootLang = this.isSupportedLang(queryLang)
|
||||||
? queryLang
|
? queryLang
|
||||||
: this.currentLang();
|
: this.defaultLang;
|
||||||
if (rootLang !== this.currentLang()) {
|
if (rootLang !== this.currentLang()) {
|
||||||
this.applyLanguage(rootLang);
|
this.applyLanguage(rootLang);
|
||||||
}
|
}
|
||||||
@@ -180,17 +169,6 @@ export class LanguageService {
|
|||||||
return typeof lang === 'string' ? lang.toLowerCase() : null;
|
return typeof lang === 'string' ? lang.toLowerCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readRequestHeader(headerName: string): string | null {
|
|
||||||
const headerValue =
|
|
||||||
this.request?.headers?.[headerName.toLowerCase()] ??
|
|
||||||
this.request?.get?.(headerName.toLowerCase());
|
|
||||||
if (Array.isArray(headerValue)) {
|
|
||||||
return headerValue[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof headerValue === 'string' ? headerValue : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSupportedLang(
|
private isSupportedLang(
|
||||||
lang: string | null | undefined,
|
lang: string | null | undefined,
|
||||||
): lang is SupportedLang {
|
): lang is SupportedLang {
|
||||||
|
|||||||
@@ -613,11 +613,11 @@
|
|||||||
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
"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_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_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_COPY": "Produktion und Support in der Schweiz.",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
"HERO_SWISS_LOCATION_2": "Zürich",
|
||||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||||
"BTN_CALCULATE": "Angebot berechnen",
|
"BTN_CALCULATE": "Angebot berechnen",
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
"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_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_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_COPY": "Production et support en Suisse.",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
|||||||
@@ -84,11 +84,11 @@
|
|||||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
"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_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_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_COPY": "Produzione e supporto in Svizzera",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
"HERO_SWISS_LOCATION_2": "Zurigo",
|
||||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||||
"BTN_CALCULATE": "Calcola Preventivo",
|
"BTN_CALCULATE": "Calcola Preventivo",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="it">
|
<html lang="it-CH">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>3D fab | Stampa 3D su misura</title>
|
<title>3D fab | Stampa 3D su misura</title>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { resolvePublicRedirectTarget } from './server-routing';
|
import { resolvePublicRedirectTarget } from './server-routing';
|
||||||
|
|
||||||
describe('server routing redirects', () => {
|
describe('server routing redirects', () => {
|
||||||
it('does not force a fixed-language redirect for the root path', () => {
|
it('redirects the root path to the default language', () => {
|
||||||
expect(resolvePublicRedirectTarget('/')).toBeNull();
|
expect(resolvePublicRedirectTarget('/')).toBe('/it');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects unprefixed public pages to the default language', () => {
|
it('redirects unprefixed public pages to the default language', () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function resolvePublicRedirectTarget(pathname: string): string | null {
|
|||||||
normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, '');
|
normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, '');
|
||||||
const segments = splitSegments(trimmedPath);
|
const segments = splitSegments(trimmedPath);
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
return null;
|
return `/${DEFAULT_LANG}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstSegment = segments[0].toLowerCase();
|
const firstSegment = segments[0].toLowerCase();
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import bootstrap from './main.server';
|
import bootstrap from './main.server';
|
||||||
import { resolveRequestOrigin } from './core/request-origin';
|
import { resolveRequestOrigin } from './core/request-origin';
|
||||||
import {
|
|
||||||
parseAcceptLanguage,
|
|
||||||
resolveInitialLanguage,
|
|
||||||
} from './app/core/i18n/language-resolution';
|
|
||||||
import { resolvePublicRedirectTarget } from './server-routing';
|
import { resolvePublicRedirectTarget } from './server-routing';
|
||||||
|
|
||||||
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -41,18 +37,6 @@ app.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
|
||||||
const acceptLanguage = req.get('accept-language');
|
|
||||||
const preferredLanguages = parseAcceptLanguage(acceptLanguage);
|
|
||||||
const lang = resolveInitialLanguage({
|
|
||||||
preferredLanguages,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader('Vary', 'Accept-Language');
|
|
||||||
res.setHeader('Cache-Control', 'private, no-store');
|
|
||||||
res.redirect(302, `/${lang}${querySuffix(req.originalUrl)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('**', (req, res, next) => {
|
app.get('**', (req, res, next) => {
|
||||||
const targetPath = resolvePublicRedirectTarget(req.path);
|
const targetPath = resolvePublicRedirectTarget(req.path);
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
|
|||||||
Reference in New Issue
Block a user