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}