/**
* This file is part of alf.io.
*
* alf.io is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* alf.io is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with alf.io. If not, see <http://www.gnu.org/licenses/>.
*/
package alfio.config;
import alfio.controller.decorator.EventDescriptor;
import alfio.manager.i18n.I18nManager;
import alfio.manager.system.ConfigurationManager;
import alfio.model.ContentLanguage;
import alfio.model.Event;
import alfio.model.system.Configuration.ConfigurationPathKey;
import alfio.util.MustacheCustomTagInterceptor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.samskivert.mustache.Mustache;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.core.env.Environment;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
import org.springframework.web.servlet.*;
import org.springframework.web.servlet.config.annotation.*;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import org.springframework.web.servlet.support.RequestContextUtils;
import org.springframework.web.servlet.view.mustache.MustacheViewResolver;
import org.springframework.web.servlet.view.mustache.jmustache.JMustacheTemplateFactory;
import org.springframework.web.servlet.view.mustache.jmustache.JMustacheTemplateLoader;
import org.springframework.web.servlet.view.mustache.jmustache.LocalizationMessageInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static alfio.model.system.ConfigurationKeys.GOOGLE_ANALYTICS_KEY;
@Configuration
@ComponentScan(basePackages = {"alfio.controller", "alfio.config"})
@EnableWebMvc
public class MvcConfiguration extends WebMvcConfigurerAdapter {
private final MessageSource messageSource;
private final JMustacheTemplateLoader templateLoader;
private final I18nManager i18nManager;
private final ConfigurationManager configurationManager;
private final Environment environment;
@Autowired
public MvcConfiguration(MessageSource messageSource,
JMustacheTemplateLoader templateLoader,
I18nManager i18nManager,
ConfigurationManager configurationManager,
Environment environment) {
this.messageSource = messageSource;
this.templateLoader = templateLoader;
this.i18nManager = i18nManager;
this.configurationManager = configurationManager;
this.environment = environment;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
ResourceHandlerRegistration reg = registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
int cacheMinutes = environment.acceptsProfiles(Initializer.PROFILE_LIVE) ? 15 : 0;
reg.setCachePeriod(cacheMinutes * 60);
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/admin/partials/index.html").setViewName("/admin/partials/main");
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getLocaleChangeInterceptor());
registry.addInterceptor(getEventLocaleSetterInterceptor());
registry.addInterceptor(getTemplateMessagesInterceptor());
registry.addInterceptor(new MustacheCustomTagInterceptor());
registry.addInterceptor(getCsrfInterceptor());
registry.addInterceptor(getCSPInterceptor());
registry.addInterceptor(getDefaultTemplateObjectsFiller());
}
@Bean
public HandlerInterceptor getEventLocaleSetterInterceptor() {
return new HandlerInterceptorAdapter() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = ((HandlerMethod) handler);
RequestMapping reqMapping = handlerMethod.getMethodAnnotation(RequestMapping.class);
//check if the request mapping value has the form "/event/{something}"
Pattern eventPattern = Pattern.compile("^/event/\\{(\\w+)}/{0,1}.*");
if (reqMapping != null && reqMapping.value().length == 1 && eventPattern.matcher(reqMapping.value()[0]).matches()) {
Matcher m = eventPattern.matcher(reqMapping.value()[0]);
m.matches();
String pathVariableName = m.group(1);
//extract the parameter name
Arrays.stream(handlerMethod.getMethodParameters())
.map(methodParameter -> methodParameter.getParameterAnnotation(PathVariable.class))
.filter(Objects::nonNull)
.map(PathVariable::value)
.filter(pathVariableName::equals)
.findFirst().ifPresent((val) -> {
//fetch the parameter value
@SuppressWarnings("unchecked")
String eventName = Optional.ofNullable(((Map<String, Object>)request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)).get(val)).orElse("").toString();
LocaleResolver resolver = RequestContextUtils.getLocaleResolver(request);
Locale locale = resolver.resolveLocale(request);
List<ContentLanguage> cl = i18nManager.getEventLanguages(eventName);
request.setAttribute("ALFIO_EVENT_NAME", eventName);
if(cl.stream().noneMatch(contentLanguage -> contentLanguage.getLanguage().equals(Optional.ofNullable(locale).orElse(Locale.ENGLISH).getLanguage()))) {
//override the user locale if it's not in the one permitted by the event
resolver.setLocale(request, response, cl.stream().findFirst().map(ContentLanguage::getLocale).orElse(Locale.ENGLISH));
} else {
resolver.setLocale(request, response, locale);
}
});
}
}
return true;
}
};
}
@Bean
public HandlerInterceptorAdapter getDefaultTemplateObjectsFiller() {
return new HandlerInterceptorAdapter() {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
Optional.ofNullable(modelAndView).ifPresent(mv -> {
mv.addObject("request", request);
final ModelMap modelMap = mv.getModelMap();
Optional.ofNullable(request.getAttribute("ALFIO_EVENT_NAME")).map(Object::toString).ifPresent(eventName -> {
List<?> availableLanguages = i18nManager.getEventLanguages(eventName);
modelMap.put("showAvailableLanguagesInPageTop", availableLanguages.size() > 1);
modelMap.put("availableLanguages", availableLanguages);
});
modelMap.putIfAbsent("event", null);
if(!StringUtils.startsWith(mv.getViewName(), "redirect:")) {
modelMap.putIfAbsent("pageTitle", "empty");
Event event = modelMap.get("event") == null ? null : modelMap.get("event") instanceof Event ? (Event) modelMap.get("event") : ((EventDescriptor) modelMap.get("event")).getEvent();
ConfigurationPathKey googleAnalyticsKey = Optional.ofNullable(event).map(e -> alfio.model.system.Configuration.from(e.getOrganizationId(), e.getId(), GOOGLE_ANALYTICS_KEY)).orElseGet(() -> alfio.model.system.Configuration.getSystemConfiguration(GOOGLE_ANALYTICS_KEY));
modelMap.putIfAbsent("analyticsEnabled", StringUtils.isNotBlank(configurationManager.getStringConfigValue(googleAnalyticsKey, "")));
}
});
}
};
}
@Bean
public HandlerInterceptor getLocaleChangeInterceptor(){
LocaleChangeInterceptor localeChangeInterceptor= new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
private HandlerInterceptor getCSPInterceptor() {
return new HandlerInterceptorAdapter() {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
// http://www.html5rocks.com/en/tutorials/security/content-security-policy/
// lockdown policy
response.addHeader("Content-Security-Policy", "default-src 'none'; "//block all by default
+ " script-src 'self' https://js.stripe.com/ https://api.stripe.com/ https://ssl.google-analytics.com/;"//
+ " style-src 'self' 'unsafe-inline';" // unsafe-inline for style is acceptable...
+ " img-src 'self' https: data:;"//
+ " child-src 'self';"//webworker
+ " frame-src 'self' https://js.stripe.com;"
+ " font-src 'self';"//
+ " media-src blob: 'self';"//for loading camera api
+ " connect-src 'self' https://api.stripe.com;" //<- currently stripe.js use jsonp but if they switch to xmlhttprequest+cors we will be ready
+ (environment.acceptsProfiles(Initializer.PROFILE_DEBUG_CSP) ? " report-uri /report-csp-violation" : ""));
}
};
}
@Bean
public HandlerInterceptor getCsrfInterceptor() {
return new HandlerInterceptorAdapter() {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
Optional.ofNullable(modelAndView).ifPresent(mv -> mv.addObject(WebSecurityConfig.CSRF_PARAM_NAME, request.getAttribute(CsrfToken.class.getName())));
}
};
}
@Bean
public LocalizationMessageInterceptor getTemplateMessagesInterceptor() {
LocalizationMessageInterceptor interceptor = new LocalizationMessageInterceptor();
interceptor.setLocaleResolver(getLocaleResolver());
interceptor.setMessageSource(messageSource);
return interceptor;
}
@Bean(name = "localeResolver")
public LocaleResolver getLocaleResolver() {
return new SessionLocaleResolver();
}
@Bean
public ViewResolver getViewResolver(Environment env) throws Exception {
MustacheViewResolver viewResolver = new MustacheViewResolver();
viewResolver.setSuffix("");
viewResolver.setTemplateFactory(getTemplateFactory());
viewResolver.setOrder(1);
//disable caching if we are in dev mode
viewResolver.setCache(env.acceptsProfiles(Initializer.PROFILE_LIVE));
viewResolver.setContentType("text/html;charset=UTF-8");
return viewResolver;
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(jacksonMessageConverter());
StringHttpMessageConverter converter = new StringHttpMessageConverter();
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.ALL));
converters.add(converter);
}
@Bean
public JMustacheTemplateFactory getTemplateFactory() throws Exception {
final JMustacheTemplateFactory templateFactory = new JMustacheTemplateFactory();
templateFactory.setPrefix("/WEB-INF/templates");
templateFactory.setSuffix(".ms");
templateFactory.setTemplateLoader(templateLoader);
templateFactory.setCompiler(Mustache.compiler()
.escapeHTML(true)
.standardsMode(false)
.defaultValue("")
.nullValue("")
.withFormatter(
(o) -> {
if(o instanceof ZonedDateTime) {
return DateTimeFormatter.ISO_ZONED_DATE_TIME.format((ZonedDateTime) o);
} else if(o instanceof DefaultMessageSourceResolvable) {
DefaultMessageSourceResolvable m = ((DefaultMessageSourceResolvable) o);
return m.getCode()+ " " + Arrays.stream(Optional.ofNullable(m.getArguments()).orElse(new Object[]{})).map(x -> "["+x.toString()+"]").collect(Collectors.joining(" "));
} else {
return String.valueOf(o);
}
})
.withLoader(templateLoader));
templateFactory.afterPropertiesSet();
return templateFactory;
}
@Bean
public MappingJackson2HttpMessageConverter jacksonMessageConverter() {
final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(objectMapper());
return converter;
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
return mapper;
}
@Bean
public CommonsMultipartResolver multipartResolver() {
return new CommonsMultipartResolver();
}
}