dev #51

Merged
JoeKung merged 5 commits from dev into main 2026-03-22 23:06:02 +01:00
18 changed files with 261 additions and 122 deletions
Showing only changes of commit 74d1b16b7c - Show all commits

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -399,6 +399,8 @@ public class PublicShopCatalogService {
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String normalizedLanguage = normalizeLanguage(language);
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductSummaryDto(
entry.product().getId(),
@@ -417,7 +419,7 @@ public class PublicShopCatalogService {
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images),
toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
publicPathSegment,
localizedPaths
);
}
@@ -429,9 +431,10 @@ public class PublicShopCatalogService {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
String normalizedLanguage = normalizeLanguage(language);
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductDetailDto(
entry.product().getId(),
return new ShopProductDetailDto(entry.product().getId(),
entry.product().getSlug(),
entry.product().getNameForLanguage(language),
entry.product().getExcerptForLanguage(language),
@@ -458,7 +461,7 @@ public class PublicShopCatalogService {
selectPrimaryMedia(images),
images,
toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
publicPathSegment,
localizedPaths
);
}

View File

@@ -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}

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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;
}
}

View File

@@ -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<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;
}

View File

@@ -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();

View File

@@ -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<Record<SupportedLang, string>>;
@@ -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 {

View File

@@ -613,11 +613,11 @@
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_TITLE": "Mit Sitz in der Schweiz",
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_2": "Zürich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
"BTN_CALCULATE": "Angebot berechnen",

View File

@@ -84,7 +84,7 @@
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_TITLE": "Basés en Suisse",
"HERO_SWISS_COPY": "Production et support en Suisse.",
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
"HERO_SWISS_LOCATION_1": "Ticino",

View File

@@ -84,11 +84,11 @@
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_TITLE": "Con sede in Svizzera",
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_2": "Zurigo",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
"BTN_CALCULATE": "Calcola Preventivo",

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="it">
<html lang="it-CH">
<head>
<meta charset="utf-8" />
<title>3D fab | Stampa 3D su misura</title>

View File

@@ -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', () => {

View File

@@ -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();

View File

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