package pl.matisoft.soy.ajax; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.template.soy.msgs.SoyMsgBundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.client.HttpClientErrorException; import pl.matisoft.soy.ajax.auth.AuthManager; import pl.matisoft.soy.ajax.auth.PermissableAuthManager; import pl.matisoft.soy.ajax.process.OutputProcessor; import pl.matisoft.soy.ajax.utils.I18nUtils; import pl.matisoft.soy.ajax.utils.PathUtils; import pl.matisoft.soy.bundle.EmptySoyMsgBundleResolver; import pl.matisoft.soy.bundle.SoyMsgBundleResolver; import pl.matisoft.soy.compile.EmptyTofuCompiler; import pl.matisoft.soy.compile.TofuCompiler; import pl.matisoft.soy.config.SoyViewConfigDefaults; import pl.matisoft.soy.locale.EmptyLocaleProvider; import pl.matisoft.soy.locale.LocaleProvider; import pl.matisoft.soy.template.EmptyTemplateFilesResolver; import pl.matisoft.soy.template.TemplateFilesResolver; import javax.annotation.PostConstruct; import javax.annotation.concurrent.ThreadSafe; import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.net.URL; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import static org.springframework.http.HttpStatus.*; import static org.springframework.web.bind.annotation.RequestMethod.GET; @Controller @ThreadSafe public class SoyAjaxController { private final static int DEF_CACHE_MAX_SIZE = 10000; private final static String DEF_TIME_UNIT = "DAYS"; private final static int DEF_EXPIRE_AFTER_WRITE = 1; private static final Logger logger = LoggerFactory.getLogger(SoyAjaxController.class); private String cacheControl = "no-cache"; private String expiresHeaders = ""; /** maximum number of entries this cache will hold */ private int cacheMaxSize = DEF_CACHE_MAX_SIZE; /** number of time units after which once written entries will expire */ private int expireAfterWrite = DEF_EXPIRE_AFTER_WRITE; /** String used to denote a TimeUnit */ private String expireAfterWriteUnit = DEF_TIME_UNIT; /** * This is a compiled to javascript cache (compiled soy templates), which consists of key: hash * and as a value we have a Map<String,String> * In this map, a key is an array to path, example: server-time,client-words and value: * is a String with compiled template. * To prevent DDOS attack we model the first cache as a limited cache with maximum number of entries * and also an expire after write to cache */ private Cache<String, Map<String,String>> cachedJsTemplates = CacheBuilder.newBuilder() .expireAfterWrite(expireAfterWrite, TimeUnit.valueOf(expireAfterWriteUnit)) .maximumSize(cacheMaxSize) .concurrencyLevel(1) //look up a constant class, 1 is not very clear .build(); private TemplateFilesResolver templateFilesResolver = new EmptyTemplateFilesResolver(); private TofuCompiler tofuCompiler = new EmptyTofuCompiler(); private SoyMsgBundleResolver soyMsgBundleResolver = new EmptySoyMsgBundleResolver(); private LocaleProvider localeProvider = new EmptyLocaleProvider(); /** * whether debug is on or off, if it is on then caching of entries will not work * to support hot reloading while developing, if it is on, then we assume it is * like production mode and caching of compiled soy to JavaScript templates * will be working. In addition in production mode (debug off) * CacheControl (Http 1.1) and Expires (Http 1.0) http headers * will be set to user configured values. */ private boolean hotReloadMode = SoyViewConfigDefaults.DEFAULT_HOT_RELOAD_MODE; /** * character encoding, by default utf-8 */ private String encoding = SoyViewConfigDefaults.DEFAULT_ENCODING; /** * List of output processors, output processors typically perform obfuscation * of generated JavaScript code */ private List<OutputProcessor> outputProcessors = new ArrayList<OutputProcessor>(); /** * By default there is no AuthManager and an external user can compile all templates to JavaScript * This can pose security risk and therefore it is possible to change this and inject * an AuthManager implementation that will only allow to compile those templates that a developer agreed to. */ private AuthManager authManager = new PermissableAuthManager(); public SoyAjaxController() { } @PostConstruct public void init() { this.cachedJsTemplates = CacheBuilder.newBuilder() .expireAfterWrite(expireAfterWrite, TimeUnit.valueOf(expireAfterWriteUnit)) .maximumSize(cacheMaxSize) .concurrencyLevel(1) //look up a constant class, 1 is not very clear .build(); } /** * An endpoint to compile an array of soy templates to JavaScript. * * This endpoint is a preferred way of compiling soy templates to JavaScript but it requires a user to compose a url * on their own or using a helper class TemplateUrlComposer, which calculates checksum of a file and puts this in url * so that whenever a file changes, after a deployment a JavaScript, url changes and a new hash is appended to url, which enforces * getting of new compiles JavaScript resource. * * Invocation of this url may throw two types of http exceptions: * 1. notFound - usually when a TemplateResolver cannot find a template with an associated name * 2. error - usually when there is a permission error and a user is not allowed to compile a template into a JavaScript * * @param hash - some unique number that should be used when we are caching this resource in a browser and we use http cache headers * @param templateFileNames - an array of template names, e.g. client-words,server-time, which may or may not contain extension * currently three modes are supported - soy extension, js extension and no extension, which is preferred * @param disableProcessors - whether the controller should run registered outputProcessors after the compilation is complete. * @param request - HttpServletRequest * @param locale - locale * @return response entity, which wraps a compiled soy to JavaScript files. * @throws IOException - io error */ @RequestMapping(value="/soy/compileJs", method=GET) public ResponseEntity<String> compile(@RequestParam(required = false, value="hash", defaultValue = "") final String hash, @RequestParam(required = true, value = "file") final String[] templateFileNames, @RequestParam(required = false, value = "locale") String locale, @RequestParam(required = false, value = "disableProcessors", defaultValue = "false") String disableProcessors, final HttpServletRequest request) throws IOException { return compileJs(templateFileNames, hash, new Boolean(disableProcessors).booleanValue(), request, locale); } private ResponseEntity<String> compileJs(final String[] templateFileNames, final String hash, final boolean disableProcessors, final HttpServletRequest request, final String locale ) throws IOException { Preconditions.checkNotNull(templateFilesResolver, "templateFilesResolver cannot be null"); if (isHotReloadModeOff()) { final Optional<String> template = extractAndCombineAll(hash, templateFileNames); if (template.isPresent()) { return prepareResponseFor(template.get(), disableProcessors); } } try { final Map<URL,String> compiledTemplates = compileTemplates(templateFileNames, request, locale); final Optional<String> allCompiledTemplates = concatCompiledTemplates(compiledTemplates); if (!allCompiledTemplates.isPresent()) { throw notFound("Template file(s) could not be resolved."); } if (isHotReloadModeOff()) { synchronized (cachedJsTemplates) { Map<String, String> map = cachedJsTemplates.getIfPresent(hash); if (map == null) { map = new ConcurrentHashMap<String, String>(); } else { map.put(PathUtils.arrayToPath(templateFileNames), allCompiledTemplates.get()); } this.cachedJsTemplates.put(hash, map); } } return prepareResponseFor(allCompiledTemplates.get(), disableProcessors); } catch (final SecurityException ex) { return new ResponseEntity<String>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } catch (final HttpClientErrorException ex) { return new ResponseEntity<String>(ex.getMessage(), ex.getStatusCode()); } } private Optional<String> extractAndCombineAll(final String hash, final String[] templateFileNames) throws IOException { synchronized (cachedJsTemplates) { final Map<String, String> map = cachedJsTemplates.getIfPresent(hash); if (map != null) { final String template = map.get(PathUtils.arrayToPath(templateFileNames)); return Optional.fromNullable(template); } } return Optional.absent(); } private Map<URL,String> compileTemplates(final String[] templateFileNames, final HttpServletRequest request, final String locale) { final HashMap<URL,String> map = new HashMap<URL,String>(); for (final String templateFileName : templateFileNames) { try { final Optional<URL> templateUrl = templateFilesResolver.resolve(templateFileName); if (!templateUrl.isPresent()) { throw notFound("File not found:" + templateFileName); } if (!authManager.isAllowed(templateFileName)) { throw error("no permission to compile:" + templateFileName); } logger.debug("Compiling JavaScript template:" + templateUrl.orNull()); final Optional<String> templateContent = compileTemplateAndAssertSuccess(request, templateUrl, locale); if (!templateContent.isPresent()) { throw notFound("file cannot be compiled:" + templateUrl); } map.put(templateUrl.get(), templateContent.get()); } catch (final IOException e) { throw error("Unable to find file:" + templateFileName + ".soy"); } } return map; } private Optional<String> concatCompiledTemplates(final Map<URL,String> compiledTemplates) throws IOException, SecurityException { if (compiledTemplates.isEmpty()) { return Optional.absent(); } final StringBuilder allJsTemplates = new StringBuilder(); for (final String compiledTemplate : compiledTemplates.values()) { allJsTemplates.append(compiledTemplate); } return Optional.of(allJsTemplates.toString()); } private ResponseEntity<String> prepareResponseFor(final String templateContent, final boolean disableProcessors) throws IOException { final HttpHeaders headers = new HttpHeaders(); headers.add("Content-Type", "text/javascript; charset=" + encoding); headers.add("Cache-Control", hotReloadMode ? "no-cache" : cacheControl); if (StringUtils.hasText(expiresHeaders)) { headers.add("Expires", expiresHeaders); } if (disableProcessors) { return new ResponseEntity<String>(templateContent, headers, OK); } String processTemplate = templateContent; try { for (final OutputProcessor outputProcessor : outputProcessors) { final StringWriter writer = new StringWriter(); outputProcessor.process(new StringReader(templateContent), writer); processTemplate = writer.getBuffer().toString(); } return new ResponseEntity<String>(processTemplate, headers, OK); } catch(final Exception ex) { logger.warn("Unable to process template", ex); return new ResponseEntity<String>(templateContent, headers, OK); } } private Optional<String> compileTemplateAndAssertSuccess(final HttpServletRequest request, final Optional<URL> templateFile, final String locale) throws IOException { Preconditions.checkNotNull(localeProvider, "localeProvider cannot be null"); Preconditions.checkNotNull(soyMsgBundleResolver, "soyMsgBundleResolver cannot be null"); Preconditions.checkNotNull(tofuCompiler, "tofuCompiler cannot be null"); if (!templateFile.isPresent()) { return Optional.absent(); } Optional<Locale> localeOptional = Optional.fromNullable(I18nUtils.getLocaleFromString(locale)); if (!localeOptional.isPresent()) { localeOptional = localeProvider.resolveLocale(request); } final Optional<SoyMsgBundle> soyMsgBundle = soyMsgBundleResolver.resolve(localeOptional); final Optional<String> compiledTemplate = tofuCompiler.compileToJsSrc(templateFile.orNull(), soyMsgBundle.orNull()); return compiledTemplate; } private boolean isHotReloadMode() { return hotReloadMode; } private boolean isHotReloadModeOff() { return !hotReloadMode; } private HttpClientErrorException notFound(final String file) { return new HttpClientErrorException(NOT_FOUND, file); } private HttpClientErrorException error(final String file) { return new HttpClientErrorException(INTERNAL_SERVER_ERROR, file); } public void setCacheControl(final String cacheControl) { this.cacheControl = cacheControl; } public void setTemplateFilesResolver(final TemplateFilesResolver templateFilesResolver) { this.templateFilesResolver = templateFilesResolver; } public void setTofuCompiler(final TofuCompiler tofuCompiler) { this.tofuCompiler = tofuCompiler; } public void setSoyMsgBundleResolver(final SoyMsgBundleResolver soyMsgBundleResolver) { this.soyMsgBundleResolver = soyMsgBundleResolver; } public void setLocaleProvider(final LocaleProvider localeProvider) { this.localeProvider = localeProvider; } public void setHotReloadMode(final boolean hotReloadMode) { this.hotReloadMode = hotReloadMode; } public void setEncoding(final String encoding) { this.encoding = encoding; } public void setExpiresHeaders(final String expiresHeaders) { this.expiresHeaders = expiresHeaders; } public void setOutputProcessors(List<OutputProcessor> outputProcessors) { this.outputProcessors = outputProcessors; } public void setAuthManager(AuthManager authManager) { this.authManager = authManager; } public void setCacheMaxSize(int cacheMaxSize) { this.cacheMaxSize = cacheMaxSize; } public void setExpireAfterWrite(int expireAfterWrite) { this.expireAfterWrite = expireAfterWrite; } public void setExpireAfterWriteUnit(String expireAfterWriteUnit) { this.expireAfterWriteUnit = expireAfterWriteUnit; } }