001/* 002 * Copyright 2002-2020 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.servlet.i18n; 018 019import java.util.Locale; 020import java.util.TimeZone; 021 022import javax.servlet.http.Cookie; 023import javax.servlet.http.HttpServletRequest; 024import javax.servlet.http.HttpServletResponse; 025 026import org.springframework.context.i18n.LocaleContext; 027import org.springframework.context.i18n.SimpleLocaleContext; 028import org.springframework.context.i18n.TimeZoneAwareLocaleContext; 029import org.springframework.lang.Nullable; 030import org.springframework.util.Assert; 031import org.springframework.util.StringUtils; 032import org.springframework.web.servlet.LocaleContextResolver; 033import org.springframework.web.servlet.LocaleResolver; 034import org.springframework.web.util.CookieGenerator; 035import org.springframework.web.util.WebUtils; 036 037/** 038 * {@link LocaleResolver} implementation that uses a cookie sent back to the user 039 * in case of a custom setting, with a fallback to the specified default locale 040 * or the request's accept-header locale. 041 * 042 * <p>This is particularly useful for stateless applications without user sessions. 043 * The cookie may optionally contain an associated time zone value as well; 044 * alternatively, you may specify a default time zone. 045 * 046 * <p>Custom controllers can override the user's locale and time zone by calling 047 * {@code #setLocale(Context)} on the resolver, e.g. responding to a locale change 048 * request. As a more convenient alternative, consider using 049 * {@link org.springframework.web.servlet.support.RequestContext#changeLocale}. 050 * 051 * @author Juergen Hoeller 052 * @author Jean-Pierre Pawlak 053 * @since 27.02.2003 054 * @see #setDefaultLocale 055 * @see #setDefaultTimeZone 056 */ 057public class CookieLocaleResolver extends CookieGenerator implements LocaleContextResolver { 058 059 /** 060 * The name of the request attribute that holds the {@code Locale}. 061 * <p>Only used for overriding a cookie value if the locale has been 062 * changed in the course of the current request! 063 * <p>Use {@code RequestContext(Utils).getLocale()} 064 * to retrieve the current locale in controllers or views. 065 * @see org.springframework.web.servlet.support.RequestContext#getLocale 066 * @see org.springframework.web.servlet.support.RequestContextUtils#getLocale 067 */ 068 public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE"; 069 070 /** 071 * The name of the request attribute that holds the {@code TimeZone}. 072 * <p>Only used for overriding a cookie value if the locale has been 073 * changed in the course of the current request! 074 * <p>Use {@code RequestContext(Utils).getTimeZone()} 075 * to retrieve the current time zone in controllers or views. 076 * @see org.springframework.web.servlet.support.RequestContext#getTimeZone 077 * @see org.springframework.web.servlet.support.RequestContextUtils#getTimeZone 078 */ 079 public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".TIME_ZONE"; 080 081 /** 082 * The default cookie name used if none is explicitly set. 083 */ 084 public static final String DEFAULT_COOKIE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE"; 085 086 087 private boolean languageTagCompliant = true; 088 089 private boolean rejectInvalidCookies = true; 090 091 @Nullable 092 private Locale defaultLocale; 093 094 @Nullable 095 private TimeZone defaultTimeZone; 096 097 098 /** 099 * Create a new instance of the {@link CookieLocaleResolver} class 100 * using the {@link #DEFAULT_COOKIE_NAME default cookie name}. 101 */ 102 public CookieLocaleResolver() { 103 setCookieName(DEFAULT_COOKIE_NAME); 104 } 105 106 107 /** 108 * Specify whether this resolver's cookies should be compliant with BCP 47 109 * language tags instead of Java's legacy locale specification format. 110 * <p>The default is {@code true}, as of 5.1. Switch this to {@code false} 111 * for rendering Java's legacy locale specification format. For parsing, 112 * this resolver leniently accepts the legacy {@link Locale#toString} 113 * format as well as BCP 47 language tags in any case. 114 * @since 4.3 115 * @see #parseLocaleValue(String) 116 * @see #toLocaleValue(Locale) 117 * @see Locale#forLanguageTag(String) 118 * @see Locale#toLanguageTag() 119 */ 120 public void setLanguageTagCompliant(boolean languageTagCompliant) { 121 this.languageTagCompliant = languageTagCompliant; 122 } 123 124 /** 125 * Return whether this resolver's cookies should be compliant with BCP 47 126 * language tags instead of Java's legacy locale specification format. 127 * @since 4.3 128 */ 129 public boolean isLanguageTagCompliant() { 130 return this.languageTagCompliant; 131 } 132 133 /** 134 * Specify whether to reject cookies with invalid content (e.g. invalid format). 135 * <p>The default is {@code true}. Turn this off for lenient handling of parse 136 * failures, falling back to the default locale and time zone in such a case. 137 * @since 5.1.7 138 * @see #setDefaultLocale 139 * @see #setDefaultTimeZone 140 * @see #determineDefaultLocale 141 * @see #determineDefaultTimeZone 142 */ 143 public void setRejectInvalidCookies(boolean rejectInvalidCookies) { 144 this.rejectInvalidCookies = rejectInvalidCookies; 145 } 146 147 /** 148 * Return whether to reject cookies with invalid content (e.g. invalid format). 149 * @since 5.1.7 150 */ 151 public boolean isRejectInvalidCookies() { 152 return this.rejectInvalidCookies; 153 } 154 155 /** 156 * Set a fixed locale that this resolver will return if no cookie found. 157 */ 158 public void setDefaultLocale(@Nullable Locale defaultLocale) { 159 this.defaultLocale = defaultLocale; 160 } 161 162 /** 163 * Return the fixed locale that this resolver will return if no cookie found, 164 * if any. 165 */ 166 @Nullable 167 protected Locale getDefaultLocale() { 168 return this.defaultLocale; 169 } 170 171 /** 172 * Set a fixed time zone that this resolver will return if no cookie found. 173 * @since 4.0 174 */ 175 public void setDefaultTimeZone(@Nullable TimeZone defaultTimeZone) { 176 this.defaultTimeZone = defaultTimeZone; 177 } 178 179 /** 180 * Return the fixed time zone that this resolver will return if no cookie found, 181 * if any. 182 * @since 4.0 183 */ 184 @Nullable 185 protected TimeZone getDefaultTimeZone() { 186 return this.defaultTimeZone; 187 } 188 189 190 @Override 191 public Locale resolveLocale(HttpServletRequest request) { 192 parseLocaleCookieIfNecessary(request); 193 return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME); 194 } 195 196 @Override 197 public LocaleContext resolveLocaleContext(final HttpServletRequest request) { 198 parseLocaleCookieIfNecessary(request); 199 return new TimeZoneAwareLocaleContext() { 200 @Override 201 @Nullable 202 public Locale getLocale() { 203 return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME); 204 } 205 @Override 206 @Nullable 207 public TimeZone getTimeZone() { 208 return (TimeZone) request.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME); 209 } 210 }; 211 } 212 213 private void parseLocaleCookieIfNecessary(HttpServletRequest request) { 214 if (request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) { 215 Locale locale = null; 216 TimeZone timeZone = null; 217 218 // Retrieve and parse cookie value. 219 String cookieName = getCookieName(); 220 if (cookieName != null) { 221 Cookie cookie = WebUtils.getCookie(request, cookieName); 222 if (cookie != null) { 223 String value = cookie.getValue(); 224 String localePart = value; 225 String timeZonePart = null; 226 int separatorIndex = localePart.indexOf('/'); 227 if (separatorIndex == -1) { 228 // Leniently accept older cookies separated by a space... 229 separatorIndex = localePart.indexOf(' '); 230 } 231 if (separatorIndex >= 0) { 232 localePart = value.substring(0, separatorIndex); 233 timeZonePart = value.substring(separatorIndex + 1); 234 } 235 try { 236 locale = (!"-".equals(localePart) ? parseLocaleValue(localePart) : null); 237 if (timeZonePart != null) { 238 timeZone = StringUtils.parseTimeZoneString(timeZonePart); 239 } 240 } 241 catch (IllegalArgumentException ex) { 242 if (isRejectInvalidCookies() && 243 request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) { 244 throw new IllegalStateException("Encountered invalid locale cookie '" + 245 cookieName + "': [" + value + "] due to: " + ex.getMessage()); 246 } 247 else { 248 // Lenient handling (e.g. error dispatch): ignore locale/timezone parse exceptions 249 if (logger.isDebugEnabled()) { 250 logger.debug("Ignoring invalid locale cookie '" + cookieName + 251 "': [" + value + "] due to: " + ex.getMessage()); 252 } 253 } 254 } 255 if (logger.isTraceEnabled()) { 256 logger.trace("Parsed cookie value [" + cookie.getValue() + "] into locale '" + locale + 257 "'" + (timeZone != null ? " and time zone '" + timeZone.getID() + "'" : "")); 258 } 259 } 260 } 261 262 request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME, 263 (locale != null ? locale : determineDefaultLocale(request))); 264 request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME, 265 (timeZone != null ? timeZone : determineDefaultTimeZone(request))); 266 } 267 } 268 269 @Override 270 public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) { 271 setLocaleContext(request, response, (locale != null ? new SimpleLocaleContext(locale) : null)); 272 } 273 274 @Override 275 public void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response, 276 @Nullable LocaleContext localeContext) { 277 278 Assert.notNull(response, "HttpServletResponse is required for CookieLocaleResolver"); 279 280 Locale locale = null; 281 TimeZone timeZone = null; 282 if (localeContext != null) { 283 locale = localeContext.getLocale(); 284 if (localeContext instanceof TimeZoneAwareLocaleContext) { 285 timeZone = ((TimeZoneAwareLocaleContext) localeContext).getTimeZone(); 286 } 287 addCookie(response, 288 (locale != null ? toLocaleValue(locale) : "-") + (timeZone != null ? '/' + timeZone.getID() : "")); 289 } 290 else { 291 removeCookie(response); 292 } 293 request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME, 294 (locale != null ? locale : determineDefaultLocale(request))); 295 request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME, 296 (timeZone != null ? timeZone : determineDefaultTimeZone(request))); 297 } 298 299 300 /** 301 * Parse the given locale value coming from an incoming cookie. 302 * <p>The default implementation calls {@link StringUtils#parseLocale(String)}, 303 * accepting the {@link Locale#toString} format as well as BCP 47 language tags. 304 * @param localeValue the locale value to parse 305 * @return the corresponding {@code Locale} instance 306 * @since 4.3 307 * @see StringUtils#parseLocale(String) 308 */ 309 @Nullable 310 protected Locale parseLocaleValue(String localeValue) { 311 return StringUtils.parseLocale(localeValue); 312 } 313 314 /** 315 * Render the given locale as a text value for inclusion in a cookie. 316 * <p>The default implementation calls {@link Locale#toString()} 317 * or JDK 7's {@link Locale#toLanguageTag()}, depending on the 318 * {@link #setLanguageTagCompliant "languageTagCompliant"} configuration property. 319 * @param locale the locale to stringify 320 * @return a String representation for the given locale 321 * @since 4.3 322 * @see #isLanguageTagCompliant() 323 */ 324 protected String toLocaleValue(Locale locale) { 325 return (isLanguageTagCompliant() ? locale.toLanguageTag() : locale.toString()); 326 } 327 328 /** 329 * Determine the default locale for the given request, 330 * Called if no locale cookie has been found. 331 * <p>The default implementation returns the specified default locale, 332 * if any, else falls back to the request's accept-header locale. 333 * @param request the request to resolve the locale for 334 * @return the default locale (never {@code null}) 335 * @see #setDefaultLocale 336 * @see javax.servlet.http.HttpServletRequest#getLocale() 337 */ 338 protected Locale determineDefaultLocale(HttpServletRequest request) { 339 Locale defaultLocale = getDefaultLocale(); 340 if (defaultLocale == null) { 341 defaultLocale = request.getLocale(); 342 } 343 return defaultLocale; 344 } 345 346 /** 347 * Determine the default time zone for the given request, 348 * Called if no time zone cookie has been found. 349 * <p>The default implementation returns the specified default time zone, 350 * if any, or {@code null} otherwise. 351 * @param request the request to resolve the time zone for 352 * @return the default time zone (or {@code null} if none defined) 353 * @see #setDefaultTimeZone 354 */ 355 @Nullable 356 protected TimeZone determineDefaultTimeZone(HttpServletRequest request) { 357 return getDefaultTimeZone(); 358 } 359 360}