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