/* * Copyright (C) 2005-2007 Alfresco Software Limited. * * This program 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 2 * of the License, or (at your option) any later version. * This program 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 this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * As a special exception to the terms and conditions of version 2.0 of * the GPL, you may redistribute this Program in connection with Free/Libre * and Open Source Software ("FLOSS") applications as described in Alfresco's * FLOSS exception. You should have recieved a copy of the text describing * the FLOSS exception, and it is also available here: * http://www.alfresco.com/legal/licensing */ package org.alfresco.web.app.servlet; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Reader; import java.io.UnsupportedEncodingException; import java.io.Writer; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.alfresco.config.Config; import org.alfresco.config.ConfigService; import org.alfresco.config.JNDIConstants; import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.repo.avm.AVMNodeConverter; import org.alfresco.repo.template.ClassPathRepoTemplateLoader; import org.alfresco.service.cmr.avm.AVMNotFoundException; import org.alfresco.service.cmr.avm.AVMService; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.security.AuthenticationService; import org.alfresco.web.scripts.WebScriptCache; import org.alfresco.web.scripts.WebScriptMatch; import org.alfresco.web.scripts.WebScriptRequest; import org.alfresco.web.scripts.WebScriptResponse; import org.alfresco.web.scripts.WebScriptRuntime; import org.alfresco.web.scripts.WebScriptServlet; import org.alfresco.web.scripts.WebScriptURLRequest; import org.alfresco.web.scripts.WebScriptDescription.RequiredAuthentication; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.Element; import org.dom4j.io.SAXReader; import org.springframework.context.ApplicationContext; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.WebApplicationContextUtils; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.TemplateLoader; /** * Servlet for rendering templated pages based on Webscript components. * * NOTE: No web-client helper classes should be used here! This servlet should be movable * to the headerless repo remote client with the minimum of work. * * @author Kevin Roast */ public class PageRendererServlet extends WebScriptServlet { private static Log logger = LogFactory.getLog(PageRendererServlet.class); private static final String MIMETYPE_HTML = "text/html;charset=utf-8"; private PageTemplateProcessor templateProcessor; private WebScriptTemplateLoader webscriptTemplateLoader; private Map defaultPageDefCache = null; @Override public void init() throws ServletException { super.init(); // init beans ApplicationContext context = WebApplicationContextUtils.getRequiredWebApplicationContext(getServletContext()); templateProcessor = (PageTemplateProcessor)context.getBean("pagerenderer.templateprocessor"); webscriptTemplateLoader = new WebScriptTemplateLoader(); ClassPathRepoTemplateLoader repoLoader = new ClassPathRepoTemplateLoader( serviceRegistry.getNodeService(), serviceRegistry.getContentService(), templateProcessor.getDefaultEncoding()); templateProcessor.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{ webscriptTemplateLoader, repoLoader})); templateProcessor.initConfig(); // we use a specific config service instance configService = (ConfigService)context.getBean("pagerenderer.config"); // create cache for default config defaultPageDefCache = Collections.synchronizedMap(new HashMap()); } @Override protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { String uri = req.getRequestURI(); if (logger.isDebugEnabled()) { String qs = req.getQueryString(); logger.debug("Processing Page Renderer URL: (" + req.getMethod() + ") " + uri + ((qs != null && qs.length() != 0) ? ("?" + qs) : "")); } uri = uri.substring(req.getContextPath().length()); // skip server context path StringTokenizer t = new StringTokenizer(uri, "/"); int tokenCount = t.countTokens(); t.nextToken(); // skip servlet name if (t.hasMoreTokens() == false) { throw new IllegalArgumentException("Invalid URL to PageRendererServlet: " + uri); } // retrieve the page name from the url String page = t.nextToken(); // the AVM store to retrieve pages and config from // TODO: find a better way to set the store for a website servlet String store = req.getParameter("store"); if (store == null) { throw new AlfrescoRuntimeException("'store' argument mandatory for PageRendererServlet."); } try { // lookup template path from page config in website AVM store PageDefinition pageDefinition = lookupPageDefinition(store, req.getContextPath(), page); // set response content type and charset res.setContentType(MIMETYPE_HTML); // TODO: What authentication to use here? Guest? Or all templates and webscripts // must reside in a known AVM repo - i.e. skip permissions. Template API still // uses NodeService for some calls - even for AVMTemplateNode - so will need // some kind of security context... authenticate(getServletContext(), req, res); // set the web app context path for the template loader to use when rebuilding urls LoaderPageContext context = new LoaderPageContext(); context.Path = req.getContextPath(); context.PageDef = pageDefinition; context.AVMStore = store; context.Theme = "default"; // TODO: where does this come from? this.webscriptTemplateLoader.setContext(context); // Process the template page using our custom loader - the loader will find and buffer // individual included webscript output into the main writer for the servlet page. // TODO: where does the theme name come from!? same problem as where does store name come from... String templatePath = getStoreSitePath(store, req.getContextPath()) + '/' + "themes" + '/' + "default" + '/' + "templates" + '/' + pageDefinition.TemplateName; if (logger.isDebugEnabled()) logger.debug("Page template resolved as: " + templatePath); // execute the templte to render the page - based on the current page definition processTemplatePage(templatePath, pageDefinition, req, res); } catch (Throwable err) { throw new AlfrescoRuntimeException("Error occurred during page rendering. Page id: " + page + " with error: " + err.getMessage(), err); } } /** * Execute the template to render the main page - based on the specified page definition config * @throws IOException */ private void processTemplatePage(String templatePath, PageDefinition pageDef, HttpServletRequest req, HttpServletResponse res) throws IOException { NodeRef ref = AVMNodeConverter.ToNodeRef(-1, templatePath); templateProcessor.process(ref.toString(), getModel(pageDef, req), res.getWriter()); } private Object getModel(PageDefinition pageDef, HttpServletRequest req) { // TODO: add the full template model here? // just a basic minimum model for now Map model = new HashMap(); model.put("url", new URLHelper(req.getContextPath())); model.put("title", pageDef.Title); return model; } /** * Get the page definition config object based on the page ID and AVM store. * * @param store AVM store to retrieve data from * @param page Page ID to lookup in web-pagerenderer-config.xml * * @return Path to the template content */ private PageDefinition lookupPageDefinition(String store, String contextPath, String page) { // Lookup (and cache) config for default page-definition file in root PageDefinition defaultPageDef = defaultPageDefCache.get(store); if (defaultPageDef == null) { // read default config for the site and cache the result String defaultConfigPath = getPageDefinitionPath(null, store, contextPath); defaultPageDef = readPageDefinitionConfig(defaultConfigPath, page, null); defaultPageDefCache.put(store, defaultPageDef); } // Lookup page xml config in AVM store using page name as location String configPath = getPageDefinitionPath(page, store, contextPath); // read the page definition and return it return readPageDefinitionConfig(configPath, page, defaultPageDef); } /** * Read the page definition config at the specified location. A default config object can be * supplied from which values will be taken if none are supplied in the config that is read. * * @return PageDefinition representing the config */ private PageDefinition readPageDefinitionConfig(String configPath, String page, PageDefinition defaultPageDef) { PageDefinition pageDef = null; AVMService avm = this.serviceRegistry.getAVMService(); try { // parse page definition xml config file // TODO: convert to pull parser to optimize (see importer ViewParser) SAXReader reader = new SAXReader(); try { Document document = reader.read(avm.getFileInputStream(-1, configPath)); Element rootElement = document.getRootElement(); if (!rootElement.getName().equals("page")) { throw new AlfrescoRuntimeException( "Expected 'page' root element in page-definition.xml config: " + configPath); } String title = rootElement.attributeValue("title"); String templateName = null; if (defaultPageDef != null && defaultPageDef.TemplateName != null) { // take template name from default config in case none is specified locally templateName = defaultPageDef.TemplateName; } Element templateElement = rootElement.element("template"); if (defaultPageDef != null && (templateElement == null && templateName == null)) { throw new AlfrescoRuntimeException( "No 'template' element (and no default set) found in page-definition.xml config: " + configPath); } templateName = templateElement.attributeValue("name"); if (templateName == null || templateName.length() == 0) { throw new AlfrescoRuntimeException( "The 'template' element is missing mandatory 'name' attribute in page-definition.xml config: " + configPath); } // create config object for this page and store template name for this page pageDef = new PageDefinition(templateName); pageDef.Title = title; // copy in component mappings from default config definitions first if (defaultPageDef != null) { pageDef.Components.putAll(defaultPageDef.Components); } // read the component defs for this page // removing 'disabled' components as configured locally Element componentsElements = rootElement.element("components"); if (componentsElements != null) { for (Element ce : (List)componentsElements.elements("component")) { // read the mandatory component 'id' attribute String id = ce.attributeValue("id"); if (id == null || id.length() == 0) { throw new AlfrescoRuntimeException( "A 'component' element is missing mandatory 'id' attribute in page-definition.xml config: " + configPath); } // next check for the 'disabled' boolean attribute - used to disable components // that are specified in the default config - we don't need to read further String disabled = ce.attributeValue("disabled"); if (disabled != null) { if (Boolean.parseBoolean(disabled) == true) { pageDef.Components.remove(id); continue; } } String url = ce.attributeValue("url"); if (url == null || url.length() == 0) { throw new AlfrescoRuntimeException( "A 'component' element is missing mandatory 'url' attribute in page-definition.xml config: " + configPath); } // create minimum component config definition PageComponent component = new PageComponent(id, url); // store any other component properties for (Element pe : (List)ce.elements()) { component.Properties.put(pe.getName(), pe.getTextTrim()); } // store component definition in the page definition pageDef.Components.put(component.Id, component); } } } catch (DocumentException docErr) { throw new AlfrescoRuntimeException("Failed to parse 'page-definition.xml' for page '" + page + "' in config: " + configPath, docErr); } } catch (AVMNotFoundException avmErr) { throw new AlfrescoRuntimeException("Unable to find 'page-definition.xml' for page '" + page + "' in expected location path: " + configPath, avmErr); } return pageDef; } private Config getConfig() { return this.configService.getConfig("PageRenderer"); } private static void authenticate(ServletContext sc, HttpServletRequest req, HttpServletResponse res) throws IOException { WebApplicationContext wc = WebApplicationContextUtils.getRequiredWebApplicationContext(sc); AuthenticationService auth = (AuthenticationService)wc.getBean("AuthenticationService"); auth.authenticate("admin", "admin".toCharArray()); //auth.authenticateAsGuest(); } /** * @return the path to the site assets based on the avm store and webapp context path */ private static String getStoreSitePath(String store, String contextPath) { if (contextPath.length() == 0) { contextPath = "/ROOT"; // servlet ROOT- default hidden context path } return store + ":/" + JNDIConstants.DIR_DEFAULT_WWW + '/' + JNDIConstants.DIR_DEFAULT_APPBASE + contextPath; } /** * @return the page to a Page Definition config file for the specified avm store and page */ private static String getPageDefinitionPath(String page, String store, String contextPath) { return getStoreSitePath(store, contextPath) + '/' + "WEB-INF" + '/' + "pages" + '/' + (page != null ? page + '/' : "") + "page-definition.xml"; } /** * WebScript runtime for the PageRenderer servlet. */ private class PageRendererWebScriptRuntime extends WebScriptRuntime { private String webScript; private String scriptUrl; private String encoding; private String avmStore; private PageComponent component; private ByteArrayOutputStream baOut = null; PageRendererWebScriptRuntime( PageComponent component, String avmStore, String webScript, String executeUrl, String encoding) { super(registry, serviceRegistry); this.component = component; this.avmStore = avmStore; this.webScript = webScript; this.scriptUrl = executeUrl; this.encoding = encoding; if (logger.isDebugEnabled()) logger.debug("Constructing runtime for url: " + executeUrl); } @Override protected String getScriptUrl() { return webScript; } @Override protected WebScriptRequest createRequest(WebScriptMatch match) { // set the component properties as the additional request attributes Map attributes = new HashMap(); attributes.putAll(component.Properties); // add the well known attribute - such as avm store attributes.put("store", this.avmStore); return new WebScriptPageRendererRequest(scriptUrl, match, attributes); } @Override protected WebScriptResponse createResponse() { // create a response object that we control to write to a temporary output // we later use that as the source for the webscript "template" try { this.baOut = new ByteArrayOutputStream(4096); BufferedWriter wrOut = new BufferedWriter( encoding == null ? new OutputStreamWriter(baOut) : new OutputStreamWriter(baOut, encoding)); return new WebScriptPageRendererResponse(wrOut, baOut); } catch (UnsupportedEncodingException err) { throw new AlfrescoRuntimeException("Unsupported encoding.", err); } } @Override protected boolean authenticate(RequiredAuthentication required, boolean isGuest) { // TODO: what authentication here? return true; } @Override protected String getScriptMethod() { return "GET"; } public Reader getResponseReader() { try { if (baOut == null) { return null; } else { return new BufferedReader(new InputStreamReader( encoding == null ? new ByteArrayInputStream(baOut.toByteArray()) : new ByteArrayInputStream(baOut.toByteArray()), encoding)); } } catch (UnsupportedEncodingException err) { throw new AlfrescoRuntimeException("Unsupported encoding.", err); } } } /** * Simple implementation of a WebScript URL Request for a webscript on the page */ private class WebScriptPageRendererRequest extends WebScriptURLRequest { private Map attributes; WebScriptPageRendererRequest(String scriptUrl, WebScriptMatch match, Map attributes) { super(scriptUrl, match); this.attributes = attributes; } /* (non-Javadoc) * @see org.alfresco.web.scripts.WebScriptRequest#getAttribute(java.lang.String) */ public Object getAttribute(String name) { return this.attributes.get(name); } /* (non-Javadoc) * @see org.alfresco.web.scripts.WebScriptRequest#getAttributeNames() */ public String[] getAttributeNames() { return this.attributes.keySet().toArray(new String[this.attributes.size()]); } /* (non-Javadoc) * @see org.alfresco.web.scripts.WebScriptRequest#getAttributeValues() */ public Object[] getAttributeValues() { return this.attributes.values().toArray(new String[this.attributes.size()]); } public String getAgent() { return null; } public String getServerPath() { return null; } } /** * Implementation of a WebScript Response object for PageRenderer servlet */ private class WebScriptPageRendererResponse implements WebScriptResponse { private Writer outWriter; private OutputStream outStream; public WebScriptPageRendererResponse(Writer outWriter, OutputStream outStream) { this.outWriter = outWriter; this.outStream = outStream; } public String encodeScriptUrl(String url) { // TODO: some kind of encoding required here - need to allow webscripts to call themselves // on this page - so need the servlet PageRenderer URL plus args to identify the webscript // and it's new url - similar to the JSF or Portlet runtimes return url; } public String getEncodeScriptUrlFunction(String name) { // TODO: may be required? return null; } public OutputStream getOutputStream() throws IOException { return this.outStream; } public Writer getWriter() throws IOException { return this.outWriter; } public void reset() { // not supported } public void setCache(WebScriptCache cache) { // not supported } public void setContentType(String contentType) { // not supported } public void setStatus(int status) { // not supported } } /** * Template loader that resolves and executes webscript components by looking up layout keys * in the template against the component definition service URLs for the page. */ private class WebScriptTemplateLoader implements TemplateLoader { private ThreadLocal context = new ThreadLocal(); private long last = 0L; public void closeTemplateSource(Object templateSource) throws IOException { // nothing to do } public Object findTemplateSource(String name) throws IOException { // The webscript is looked up based on the key in the #include directive - it must // be of the form [somekey] so that it can be recognised by the loader // most templates included by this loader will be children of other templates // unfortunately FreeMarker attempts to build paths for you to child templates - they are not // really children - so this information must be discarded if (name.startsWith("avm://")) { name = name.substring(name.indexOf("/[") + 1); } if (name.startsWith("[") && name.endsWith("]")) { String key = name.substring(1, name.length() - 1); if (logger.isDebugEnabled()) logger.debug("Found webscript component key: " + key); return key; } else { return null; } } public long getLastModified(Object templateSource) { return last--; } public Reader getReader(Object templateSource, String encoding) throws IOException { String key = templateSource.toString(); // lookup against component def config LoaderPageContext context = this.context.get(); PageComponent component = context.PageDef.Components.get(key); if (component == null) { // TODO: if the lookup fails, exception or just ignore render and log...? throw new AlfrescoRuntimeException("Failed to find component identified by key '" + key + "' found in template: " + context.PageDef.TemplateName); } // TODO: remove the /service prefix from all config files? String webscript = component.Url; if (webscript.lastIndexOf('?') != -1) { webscript = webscript.substring("/service".length(), webscript.lastIndexOf('?')); } else { webscript = webscript.substring("/service".length()); } // Execute the webscript and return a Reader to the textual content String executeUrl = context.Path + component.Url; PageRendererWebScriptRuntime runtime = new PageRendererWebScriptRuntime( component, context.AVMStore, webscript, executeUrl, encoding); runtime.executeScript(); // Return a reader from the runtime that executed the webscript - this effectively // returns the result as a "template" source to freemarker. Generally this will not itself // be a template but it can contain additional freemarker syntax if required. The downside // to this approach is that the result of the webscript is parsed again by freemarker - this // should be checked for performance issues. return runtime.getResponseReader(); } /** * Setter to apply the current context for this template execution. A ThreadLocal is used * to allow multiple servlet threads to execute using the same TemplateLoader (there can only be one) * but with a different context for each thread. */ public void setContext(LoaderPageContext context) { this.context.set(context); } } /** * Simple structure class representing the current page context for the template loader */ private static class LoaderPageContext { PageDefinition PageDef; String Path; String AVMStore; String Theme; } /** * Helper to return context path for generating urls */ public static class URLHelper { String context; public URLHelper(String context) { this.context = context; } public String getContext() { return context; } } /** * Simple structure class representing a single page definition */ private static class PageDefinition { public String TemplateName; public String Title; public Map Components = new HashMap(); PageDefinition(String templateId) { this.TemplateName = templateId; } } /** * Simple structure class representing a single component definition for a page */ private static class PageComponent { public String Id; public String Url; public Map Properties = new HashMap(4, 1.0f); PageComponent(String id, String url) { this.Id = id; this.Url = url; } } }