From 74d1b16b7c9be990b42b98d224379ea5cbbfc11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sun, 22 Mar 2026 21:11:33 +0100 Subject: [PATCH 1/4] fix(back-end): fix load product --- .../printcalculator/config/CorsConfig.java | 38 ++--- .../config/SecurityConfig.java | 5 +- .../shop/PublicShopCatalogService.java | 11 +- .../src/main/resources/application.properties | 1 + .../controller/AdminAuthSecurityTest.java | 38 +++++ .../AdminOrderControllerSecurityTest.java | 9 ++ ...dminShopProductControllerSecurityTest.java | 21 +++ .../shop/PublicShopCatalogServiceTest.java | 143 ++++++++++++++++++ frontend/src/app/app.config.ts | 25 --- .../core/services/language.service.spec.ts | 30 +--- .../src/app/core/services/language.service.ts | 28 +--- frontend/src/assets/i18n/de.json | 4 +- frontend/src/assets/i18n/fr.json | 2 +- frontend/src/assets/i18n/it.json | 4 +- frontend/src/index.html | 2 +- frontend/src/server-routing.spec.ts | 4 +- frontend/src/server-routing.ts | 2 +- frontend/src/server.ts | 16 -- 18 files changed, 261 insertions(+), 122 deletions(-) create mode 100644 backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index b3a9869..5157fad 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -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; } } diff --git a/backend/src/main/java/com/printcalculator/config/SecurityConfig.java b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java index e7e6670..69a9971 100644 --- a/backend/src/main/java/com/printcalculator/config/SecurityConfig.java +++ b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java @@ -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(); } diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 779258a..16687fe 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -399,6 +399,8 @@ public class PublicShopCatalogService { Map variantColorHexByMaterialAndColor, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + String normalizedLanguage = normalizeLanguage(language); + String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage); Map 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 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 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 ); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bd64820..486bb00 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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} diff --git a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java index 62511f7..557f8e8 100644 --- a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java @@ -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()) diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java index 799526d..3fed567 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java @@ -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()) diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java index fceb9aa..565ac42 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java @@ -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()) diff --git a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java new file mode 100644 index 0000000..22e883d --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java @@ -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; + } +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index a72432c..836d25d 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -29,8 +29,6 @@ import { serverOriginInterceptor } from './core/interceptors/server-origin.inter import { catchError, firstValueFrom, of } from 'rxjs'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; import { - getNavigatorLanguagePreferences, - parseAcceptLanguage, resolveInitialLanguage, SUPPORTED_LANGS, } from './core/i18n/language-resolution'; @@ -72,11 +70,6 @@ export const appConfig: ApplicationConfig = { (typeof request?.url === 'string' && request.url) || router.url || '/'; const lang = resolveInitialLanguage({ url: requestedUrl, - preferredLanguages: request - ? parseAcceptLanguage(readRequestHeader(request, 'accept-language')) - : getNavigatorLanguagePreferences( - typeof navigator === 'undefined' ? null : navigator, - ), }); return firstValueFrom( @@ -95,21 +88,3 @@ export const appConfig: ApplicationConfig = { provideClientHydration(withEventReplay()), ], }; - -function readRequestHeader( - request: { - headers?: Record; - } | 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; -} diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts index d0996b2..025338e 100644 --- a/frontend/src/app/core/services/language.service.spec.ts +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -7,7 +7,6 @@ import { } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; -import { RequestLike } from '../../../core/request-origin'; describe('LanguageService', () => { function createTranslateMock() { @@ -83,14 +82,9 @@ describe('LanguageService', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); 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 - const service = new LanguageService(translate, router, request); + const service = new LanguageService(translate, router); expect(translate.use).toHaveBeenCalledWith('it'); expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it'); @@ -103,41 +97,31 @@ describe('LanguageService', () => { 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 router = createRouterMock('/'); 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 - 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); const firstCall = navigateSpy.calls.mostRecent(); 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', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); 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 - 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); const firstCall = navigateSpy.calls.mostRecent(); diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index 2d6ab50..264f65d 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -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 { NavigationEnd, @@ -6,12 +6,7 @@ import { Router, UrlTree, } from '@angular/router'; -import { - getNavigatorLanguagePreferences, - parseAcceptLanguage, - resolveInitialLanguage, -} from '../i18n/language-resolution'; -import { RequestLike } from '../../../core/request-origin'; +import { resolveInitialLanguage } from '../i18n/language-resolution'; type SupportedLang = 'it' | 'en' | 'de' | 'fr'; type LocalizedRouteOverrides = Partial>; @@ -28,7 +23,6 @@ export class LanguageService { constructor( private translate: TranslateService, private router: Router, - @Optional() @Inject(REQUEST) private request: RequestLike | null = null, ) { this.translate.addLangs(this.supportedLangs); this.translate.setFallbackLang('it'); @@ -43,11 +37,6 @@ export class LanguageService { const initialTree = this.router.parseUrl(this.router.url); const initialLang = resolveInitialLanguage({ url: this.router.url, - preferredLanguages: this.request - ? parseAcceptLanguage(this.readRequestHeader('accept-language')) - : getNavigatorLanguagePreferences( - typeof navigator === 'undefined' ? null : navigator, - ), }); this.applyLanguage(initialLang); this.ensureLanguageInPath(initialTree); @@ -148,7 +137,7 @@ export class LanguageService { const queryLang = this.getQueryLang(urlTree); const rootLang = this.isSupportedLang(queryLang) ? queryLang - : this.currentLang(); + : this.defaultLang; if (rootLang !== this.currentLang()) { this.applyLanguage(rootLang); } @@ -180,17 +169,6 @@ export class LanguageService { 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( lang: string | null | undefined, ): lang is SupportedLang { diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 2e207c1..d2915d6 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -613,11 +613,11 @@ "HERO_TITLE": "3D-Druckservice.
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", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 7d3d95e..ccc641e 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -84,7 +84,7 @@ "HERO_TITLE": "Service d'impression 3D.
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", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index f1296cd..d4e1818 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -84,11 +84,11 @@ "HERO_TITLE": "Servizio di stampa 3D.
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", diff --git a/frontend/src/index.html b/frontend/src/index.html index 255cfe9..6fa6644 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1,5 +1,5 @@ - + 3D fab | Stampa 3D su misura diff --git a/frontend/src/server-routing.spec.ts b/frontend/src/server-routing.spec.ts index 8359abe..1cccfa4 100644 --- a/frontend/src/server-routing.spec.ts +++ b/frontend/src/server-routing.spec.ts @@ -1,8 +1,8 @@ import { resolvePublicRedirectTarget } from './server-routing'; describe('server routing redirects', () => { - it('does not force a fixed-language redirect for the root path', () => { - expect(resolvePublicRedirectTarget('/')).toBeNull(); + it('redirects the root path to the default language', () => { + expect(resolvePublicRedirectTarget('/')).toBe('/it'); }); it('redirects unprefixed public pages to the default language', () => { diff --git a/frontend/src/server-routing.ts b/frontend/src/server-routing.ts index c66c3e7..b3745e9 100644 --- a/frontend/src/server-routing.ts +++ b/frontend/src/server-routing.ts @@ -13,7 +13,7 @@ export function resolvePublicRedirectTarget(pathname: string): string | null { normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, ''); const segments = splitSegments(trimmedPath); if (segments.length === 0) { - return null; + return `/${DEFAULT_LANG}`; } const firstSegment = segments[0].toLowerCase(); diff --git a/frontend/src/server.ts b/frontend/src/server.ts index c99614e..459986e 100644 --- a/frontend/src/server.ts +++ b/frontend/src/server.ts @@ -5,10 +5,6 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import bootstrap from './main.server'; import { resolveRequestOrigin } from './core/request-origin'; -import { - parseAcceptLanguage, - resolveInitialLanguage, -} from './app/core/i18n/language-resolution'; import { resolvePublicRedirectTarget } from './server-routing'; 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) => { const targetPath = resolvePublicRedirectTarget(req.path); if (!targetPath) { -- 2.49.1 From cc343ee27c33498d5a498d49269423eae962e7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sun, 22 Mar 2026 21:11:48 +0100 Subject: [PATCH 2/4] fix(back-end): fix csrm and cors --- .../config/AllowedOriginService.java | 88 +++++++++++++++++++ .../security/AdminCsrfProtectionFilter.java | 60 +++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 backend/src/main/java/com/printcalculator/config/AllowedOriginService.java create mode 100644 backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java diff --git a/backend/src/main/java/com/printcalculator/config/AllowedOriginService.java b/backend/src/main/java/com/printcalculator/config/AllowedOriginService.java new file mode 100644 index 0000000..679b309 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/config/AllowedOriginService.java @@ -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 allowedOrigins; + + public AllowedOriginService( + @Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl, + @Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins + ) { + LinkedHashSet 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 getAllowedOrigins() { + return allowedOrigins; + } + + public boolean isAllowed(String rawOriginOrUrl) { + String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl); + return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin); + } + + private void addConfiguredOrigin(Set 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); + } +} diff --git a/backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java b/backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java new file mode 100644 index 0000000..47321d4 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java @@ -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 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; + } +} -- 2.49.1 From b3171962171c229552fd77dcef7ca8c286a2cfe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sun, 22 Mar 2026 22:41:12 +0100 Subject: [PATCH 3/4] fix(front-end): redirect --- frontend/src/app/app.config.ts | 25 +++++++++++ .../core/services/language.service.spec.ts | 30 ++++++++++---- .../src/app/core/services/language.service.ts | 28 +++++++++++-- frontend/src/server-routing.spec.ts | 4 +- frontend/src/server-routing.ts | 2 +- frontend/src/server.ts | 41 +++++++++++++++++++ 6 files changed, 117 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 836d25d..a72432c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -29,6 +29,8 @@ import { serverOriginInterceptor } from './core/interceptors/server-origin.inter import { catchError, firstValueFrom, of } from 'rxjs'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, resolveInitialLanguage, SUPPORTED_LANGS, } from './core/i18n/language-resolution'; @@ -70,6 +72,11 @@ export const appConfig: ApplicationConfig = { (typeof request?.url === 'string' && request.url) || router.url || '/'; const lang = resolveInitialLanguage({ url: requestedUrl, + preferredLanguages: request + ? parseAcceptLanguage(readRequestHeader(request, 'accept-language')) + : getNavigatorLanguagePreferences( + typeof navigator === 'undefined' ? null : navigator, + ), }); return firstValueFrom( @@ -88,3 +95,21 @@ export const appConfig: ApplicationConfig = { provideClientHydration(withEventReplay()), ], }; + +function readRequestHeader( + request: { + headers?: Record; + } | 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; +} diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts index 025338e..d0996b2 100644 --- a/frontend/src/app/core/services/language.service.spec.ts +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -7,6 +7,7 @@ import { } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; +import { RequestLike } from '../../../core/request-origin'; describe('LanguageService', () => { function createTranslateMock() { @@ -82,9 +83,14 @@ describe('LanguageService', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); 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 - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); expect(translate.use).toHaveBeenCalledWith('it'); expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it'); @@ -97,31 +103,41 @@ describe('LanguageService', () => { expect(navOptions.replaceUrl).toBeTrue(); }); - it('uses the default language on the root URL', () => { + it('uses the preferred browser language on the root URL', () => { const translate = createTranslateMock(); const router = createRouterMock('/'); 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 - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); - expect(translate.use).toHaveBeenCalledWith('it'); + expect(translate.use).toHaveBeenCalledWith('de'); expect(navigateSpy).toHaveBeenCalledTimes(1); const firstCall = navigateSpy.calls.mostRecent(); const tree = firstCall.args[0] as UrlTree; - expect(router.serializeUrl(tree)).toBe('/it'); + expect(router.serializeUrl(tree)).toBe('/de'); }); it('uses the default language for non-root URLs without a language prefix', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); 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 - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); - expect(translate.use).toHaveBeenCalledWith('it'); + expect(translate.use).toHaveBeenCalledWith('de'); expect(navigateSpy).toHaveBeenCalledTimes(1); const firstCall = navigateSpy.calls.mostRecent(); diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index 264f65d..2d6ab50 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal } from '@angular/core'; +import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { NavigationEnd, @@ -6,7 +6,12 @@ import { Router, UrlTree, } from '@angular/router'; -import { resolveInitialLanguage } from '../i18n/language-resolution'; +import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, + resolveInitialLanguage, +} from '../i18n/language-resolution'; +import { RequestLike } from '../../../core/request-origin'; type SupportedLang = 'it' | 'en' | 'de' | 'fr'; type LocalizedRouteOverrides = Partial>; @@ -23,6 +28,7 @@ export class LanguageService { constructor( private translate: TranslateService, private router: Router, + @Optional() @Inject(REQUEST) private request: RequestLike | null = null, ) { this.translate.addLangs(this.supportedLangs); this.translate.setFallbackLang('it'); @@ -37,6 +43,11 @@ export class LanguageService { const initialTree = this.router.parseUrl(this.router.url); const initialLang = resolveInitialLanguage({ url: this.router.url, + preferredLanguages: this.request + ? parseAcceptLanguage(this.readRequestHeader('accept-language')) + : getNavigatorLanguagePreferences( + typeof navigator === 'undefined' ? null : navigator, + ), }); this.applyLanguage(initialLang); this.ensureLanguageInPath(initialTree); @@ -137,7 +148,7 @@ export class LanguageService { const queryLang = this.getQueryLang(urlTree); const rootLang = this.isSupportedLang(queryLang) ? queryLang - : this.defaultLang; + : this.currentLang(); if (rootLang !== this.currentLang()) { this.applyLanguage(rootLang); } @@ -169,6 +180,17 @@ export class LanguageService { 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( lang: string | null | undefined, ): lang is SupportedLang { diff --git a/frontend/src/server-routing.spec.ts b/frontend/src/server-routing.spec.ts index 1cccfa4..2cf9d58 100644 --- a/frontend/src/server-routing.spec.ts +++ b/frontend/src/server-routing.spec.ts @@ -1,8 +1,8 @@ import { resolvePublicRedirectTarget } from './server-routing'; describe('server routing redirects', () => { - it('redirects the root path to the default language', () => { - expect(resolvePublicRedirectTarget('/')).toBe('/it'); + it('does not handle the root path because it is resolved separately', () => { + expect(resolvePublicRedirectTarget('/')).toBeNull(); }); it('redirects unprefixed public pages to the default language', () => { diff --git a/frontend/src/server-routing.ts b/frontend/src/server-routing.ts index b3745e9..c66c3e7 100644 --- a/frontend/src/server-routing.ts +++ b/frontend/src/server-routing.ts @@ -13,7 +13,7 @@ export function resolvePublicRedirectTarget(pathname: string): string | null { normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, ''); const segments = splitSegments(trimmedPath); if (segments.length === 0) { - return `/${DEFAULT_LANG}`; + return null; } const firstSegment = segments[0].toLowerCase(); diff --git a/frontend/src/server.ts b/frontend/src/server.ts index 459986e..13580e1 100644 --- a/frontend/src/server.ts +++ b/frontend/src/server.ts @@ -5,6 +5,10 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import bootstrap from './main.server'; import { resolveRequestOrigin } from './core/request-origin'; +import { + parseAcceptLanguage, + resolveInitialLanguage, +} from './app/core/i18n/language-resolution'; import { resolvePublicRedirectTarget } from './server-routing'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); @@ -37,6 +41,25 @@ app.get( }), ); +app.get('/', (req, res) => { + 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, User-Agent'); + res.setHeader('Cache-Control', 'private, no-store'); + res.redirect( + stableRedirect ? 308 : 302, + `/${stableRedirect ? 'it' : lang}${querySuffix(req.originalUrl)}`, + ); +}); + app.get('**', (req, res, next) => { const targetPath = resolvePublicRedirectTarget(req.path); if (!targetPath) { @@ -83,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, + ); +} -- 2.49.1 From 254ff36c5027fc38de2580ddbd5085a1a6c97323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sun, 22 Mar 2026 23:02:59 +0100 Subject: [PATCH 4/4] fix(front-end): seo improvemnts --- frontend/public/sitemap-static.xml | 8 +++--- .../src/app/core/services/seo.service.spec.ts | 28 +++++++++++++++++++ frontend/src/app/core/services/seo.service.ts | 26 ++++++++++++++--- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/frontend/public/sitemap-static.xml b/frontend/public/sitemap-static.xml index 795768d..a7da08d 100644 --- a/frontend/public/sitemap-static.xml +++ b/frontend/public/sitemap-static.xml @@ -6,7 +6,7 @@ - + weekly 1.0 @@ -16,7 +16,7 @@ - + weekly 1.0 @@ -26,7 +26,7 @@ - + weekly 1.0 @@ -36,7 +36,7 @@ - + weekly 1.0 diff --git a/frontend/src/app/core/services/seo.service.spec.ts b/frontend/src/app/core/services/seo.service.spec.ts index 3a8775e..df474d3 100644 --- a/frontend/src/app/core/services/seo.service.spec.ts +++ b/frontend/src/app/core/services/seo.service.spec.ts @@ -117,6 +117,34 @@ describe('SeoService', () => { expect(ogLocaleCall?.[0].content).toBe('it_CH'); }); + it('uses the locale-adaptive root as x-default for home pages', () => { + createService({ + url: '/de', + data: { + seoTitleKey: 'SEO.ROUTES.HOME.TITLE', + seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION', + }, + translations: { + 'SEO.ROUTES.HOME.TITLE': '3D-Druck in Zürich | 3D fab', + 'SEO.ROUTES.HOME.DESCRIPTION': '3D-Druckservice in Zürich', + }, + }); + + const alternates = Array.from( + document.head.querySelectorAll( + 'link[rel="alternate"][data-seo-managed="true"]', + ), + ).map((node) => ({ + hreflang: node.getAttribute('hreflang'), + href: node.getAttribute('href'), + })); + + expect(alternates).toContain({ + hreflang: 'x-default', + href: `${document.location.origin}/`, + }); + }); + it('resolves translated route metadata for the active language', () => { const { meta, title } = createService({ url: '/en/about', diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index f1b0b05..b04b060 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -105,7 +105,7 @@ export class SeoService { cleanPath, canonicalPath, alternates, - alternates.it ?? canonicalPath, + this.buildXDefaultPath(canonicalPath, alternates), lang, ); } @@ -119,8 +119,7 @@ export class SeoService { const alternates = this.normalizeAlternatePaths(override.alternates); const xDefault = this.normalizeSeoPath(override.xDefault) ?? - alternates?.it ?? - canonicalPath; + this.buildXDefaultPath(canonicalPath, alternates); this.applySeoValues( title, @@ -162,7 +161,7 @@ export class SeoService { cleanPath, canonicalPath, alternates, - alternates.it ?? canonicalPath, + this.buildXDefaultPath(canonicalPath, alternates), lang, ); } @@ -360,6 +359,25 @@ export class SeoService { }, {}); } + private buildXDefaultPath( + canonicalPath: string | null, + alternates: SeoMap | null, + ): string | null { + if (canonicalPath && this.isLocalizedHomePath(canonicalPath)) { + return '/'; + } + + return alternates?.it ?? canonicalPath; + } + + private isLocalizedHomePath(path: string): boolean { + const segments = path.split('/').filter(Boolean); + return ( + segments.length === 1 && + this.supportedLangSet.has(segments[0] as SupportedLang) + ); + } + private normalizeAlternatePaths( paths: SeoMap | null | undefined, ): SeoMap | null { -- 2.49.1