001/*
002 *  Copyright 2012 Anyware Services
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 *      http://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 */
016package org.ametys.core.ui.dispatcher;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.UnsupportedEncodingException;
021import java.net.URLDecoder;
022import java.util.ArrayList;
023import java.util.Collections;
024import java.util.Enumeration;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.cocoon.ProcessingException;
032import org.apache.cocoon.ResourceNotFoundException;
033import org.apache.cocoon.environment.ObjectModelHelper;
034import org.apache.cocoon.environment.Request;
035import org.apache.cocoon.generation.ServiceableGenerator;
036import org.apache.cocoon.util.location.LocatedException;
037import org.apache.cocoon.xml.AttributesImpl;
038import org.apache.cocoon.xml.XMLUtils;
039import org.apache.commons.io.IOUtils;
040import org.apache.commons.lang.StringUtils;
041import org.apache.commons.lang.exception.ExceptionUtils;
042import org.apache.excalibur.source.Source;
043import org.apache.excalibur.source.SourceResolver;
044import org.apache.excalibur.xml.sax.SAXParser;
045import org.xml.sax.Attributes;
046import org.xml.sax.ContentHandler;
047import org.xml.sax.InputSource;
048import org.xml.sax.SAXException;
049
050import org.ametys.core.authentication.AuthenticateAction;
051import org.ametys.core.right.RightManager;
052import org.ametys.core.util.IgnoreRootHandler;
053import org.ametys.core.util.JSONUtils;
054import org.ametys.runtime.workspace.WorkspaceMatcher;
055
056/**
057 * This generator read the request incoming from the client org.ametys.servercomm.ServerComm component,
058 * then dispatch it to given url
059 * and aggregate the result 
060 */
061public class DispatchGenerator extends ServiceableGenerator
062{
063    private SourceResolver _resolver;
064    private SAXParser _saxParser;
065    private DispatchProcessExtensionPoint _dispatchProcessExtensionPoint;
066    private JSONUtils _jsonUtils;
067    
068    @Override
069    public void service(ServiceManager smanager) throws ServiceException
070    {
071        super.service(smanager);
072        
073        _resolver = (SourceResolver) smanager.lookup(org.apache.excalibur.source.SourceResolver.ROLE);
074        _saxParser = (SAXParser) manager.lookup(SAXParser.ROLE);
075        _dispatchProcessExtensionPoint = (DispatchProcessExtensionPoint) manager.lookup(DispatchProcessExtensionPoint.ROLE);
076        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
077    }
078    
079    @Override
080    public void generate() throws IOException, SAXException, ProcessingException
081    {
082        String parametersAsJSONString = _getRequestBody();
083        Map<String, Object> parametersAsMap = _jsonUtils.convertJsonToMap(parametersAsJSONString);
084        
085        String contextAsJSONString = _getRequestContext();
086        Map<String, Object> contextAsMap = _jsonUtils.convertJsonToMap(contextAsJSONString);
087
088        contentHandler.startDocument();
089        XMLUtils.startElement(contentHandler, "responses");
090        
091        _dispatching(parametersAsMap, contextAsMap);
092        
093        XMLUtils.endElement(contentHandler, "responses");
094        contentHandler.endDocument();
095    }
096
097    private String _getRequestBody()
098    {
099        return ObjectModelHelper.getRequest(objectModel).getParameter("content");
100    }
101
102    private String _getRequestContext()
103    {
104        return ObjectModelHelper.getRequest(objectModel).getParameter("context.parameters");
105    }
106
107    @SuppressWarnings("unchecked")
108    private void _dispatching(Map<String, Object> parametersAsMap, Map<String, Object> contextAsMap) throws SAXException
109    {
110        Map<String, Object> attributes = _saveRequestAttributes();
111        contextAsMap.putAll(transmitAttributes(attributes));
112        
113        for (String parameterKey : parametersAsMap.keySet())
114        {
115            long t0 = System.currentTimeMillis();
116
117            _removeRequestAttributes();
118            
119            for (String extension : _dispatchProcessExtensionPoint.getExtensionsIds())
120            {
121                DispatchRequestProcess processor = _dispatchProcessExtensionPoint.getExtension(extension);
122                processor.preProcess(ObjectModelHelper.getRequest(objectModel));
123            }
124
125            _setContextInRequestAttributes(contextAsMap);
126
127            Map<String, Object> parameterObject = (Map<String, Object>) parametersAsMap.get(parameterKey);
128
129            String pluginOrWorkspace = (String) parameterObject.get("pluginOrWorkspace");
130            String relativeUrl = (String) parameterObject.get("url");
131            String responseType = (String) parameterObject.get("responseType");
132            
133            Map<String, Object> requestParameters = (Map<String, Object>) parameterObject.get("parameters");
134            
135            Source response = null;
136
137            ResponseHandler responseHandler = null;
138            try
139            {
140                String url = _createUrl(pluginOrWorkspace, relativeUrl, requestParameters != null ? requestParameters : new HashMap<String, Object>());
141                
142                if (getLogger().isInfoEnabled())
143                {
144                    getLogger().info("Dispatching url '" + url + "'");
145                }
146
147                response = _resolver.resolveURI(url, null, requestParameters);
148
149                responseHandler = new ResponseHandler(contentHandler, parameterKey, "200", t0);
150                
151                try (InputStream is = response.getInputStream())
152                {
153                    if ("xml".equalsIgnoreCase(responseType))
154                    {
155                        // DO NOT USE SitemapSource.toSAX in this case
156                        _saxParser.parse(new InputSource(is), responseHandler);
157                    }
158                    else 
159                    {
160                        responseHandler.startDocument();
161                        
162                        String data = IOUtils.toString(is, "UTF-8");
163                        if ("xml2text".equalsIgnoreCase(responseType))
164                        {
165                            // removing xml prolog and surrounding 'text' tag
166                            data = data.substring(data.indexOf(">", data.indexOf("?>") + 2) + 1, data.lastIndexOf("<"));
167                        }
168                        XMLUtils.data(responseHandler, data);
169                        
170                        responseHandler.endDocument();
171                    }
172                }
173            }
174            catch (Throwable e)
175            {
176                String message = String.format("Can not dispatch request '%s' : '%s' '%s' '%s'",  parameterKey , pluginOrWorkspace,  relativeUrl,  requestParameters);
177                
178                // Ensure SAXException are unrolled the right way
179                getLogger().error(message, new LocatedException(message, e));
180                
181                // Makes the output xml ok 
182                if (responseHandler != null)
183                {
184                    responseHandler.exceptionFinish();
185                }
186                
187                Throwable t = _unroll(e);
188                
189                String code = "500";
190                if (t instanceof ResourceNotFoundException || t.toString().startsWith("org.apache.cocoon.ResourceNotFoundException:"))
191                {
192                    code = "404";
193                }
194                
195                long t1 = System.currentTimeMillis();
196                
197                AttributesImpl attrs = new AttributesImpl();
198                attrs.addCDATAAttribute("id", parameterKey);
199                attrs.addCDATAAttribute("code", code);
200                attrs.addCDATAAttribute("duration", Long.toString(t1 - t0));
201                
202                String exceptionMessage = t.getMessage();
203
204                XMLUtils.startElement(contentHandler, "response", attrs);
205                XMLUtils.createElement(contentHandler, "message", _escape(exceptionMessage != null ? exceptionMessage : ""));
206                XMLUtils.createElement(contentHandler, "stacktrace", _escape(ExceptionUtils.getFullStackTrace(t)));
207                XMLUtils.endElement(contentHandler, "response");
208            }
209            finally
210            {
211                _resolver.release(response);
212                
213                for (String extension : _dispatchProcessExtensionPoint.getExtensionsIds())
214                {
215                    DispatchRequestProcess processor = _dispatchProcessExtensionPoint.getExtension(extension);
216                    processor.postProcess(ObjectModelHelper.getRequest(objectModel));
217                }
218            }
219        }
220        
221        _restoreRequestAttributes(attributes);
222    }
223    
224    /**
225     * Filters attributes that should be transmitted to the dispatched request
226     * @param attributes The full list of attributes
227     * @return The attributes filtered
228     */
229    protected Map<String, Object> transmitAttributes(Map<String, Object> attributes)
230    {
231        Map<String, Object> contextAsMap = new HashMap<>();
232        
233        String[] attributesToTransmit = new String[] 
234        {
235            AuthenticateAction.REQUEST_ATTRIBUTE_AUTHENTICATED,
236            WorkspaceMatcher.IN_WORKSPACE_URL,
237            WorkspaceMatcher.WORKSPACE_NAME,
238            WorkspaceMatcher.WORKSPACE_THEME,
239            WorkspaceMatcher.WORKSPACE_THEME_URL,
240            WorkspaceMatcher.WORKSPACE_URI,
241            RightManager.CACHE_REQUEST_ATTRIBUTE_NAME
242        };
243        
244        for (String attributeToTransmit : attributesToTransmit)
245        {
246            contextAsMap.put(attributeToTransmit, attributes.get(attributeToTransmit));
247        }
248
249        return contextAsMap;
250    }
251
252    private Throwable _unroll(Throwable initial)
253    {
254        Throwable t = initial;
255        while (t.getCause() != null || t instanceof SAXException && ((SAXException) t).getException() != null)
256        {
257            if (t instanceof SAXException)
258            {
259                t = ((SAXException) t).getException();
260            }
261            else
262            {
263                t = t.getCause();
264            }
265        }
266        
267        return t;
268    }
269    
270    /**
271     * Clean the requests attributes and add those in the map
272     * @param attributes The attributes to restore
273     */
274    private void _restoreRequestAttributes(Map<String, Object> attributes)
275    {
276        _removeRequestAttributes();
277        
278        Request request = ObjectModelHelper.getRequest(objectModel);
279        for (String attrName : attributes.keySet())
280        {
281            request.setAttribute(attrName, attributes.get(attrName));
282        }
283    }
284    
285    @SuppressWarnings("unchecked")
286    private void _removeRequestAttributes()
287    {
288        Request request = ObjectModelHelper.getRequest(objectModel);
289
290        List<String> attrNames = Collections.list(request.getAttributeNames());
291        for (String attrName : attrNames)
292        {
293            request.removeAttribute(attrName);
294        }
295    }
296    
297    private void _setContextInRequestAttributes(Map<String, Object> contextAsMap)
298    {
299        if (contextAsMap != null)
300        {
301            Request request = ObjectModelHelper.getRequest(objectModel);
302    
303            for (String name : contextAsMap.keySet())
304            {
305                request.setAttribute(name, contextAsMap.get(name));
306            }
307        }
308    }
309
310    /**
311     * Transforms the request attributes into a map and clean the attributes
312     * @return A copy of all the request attributes
313     */
314    private Map<String, Object> _saveRequestAttributes()
315    {
316        Map<String, Object> attrs = new HashMap<>();
317        
318        Request request = ObjectModelHelper.getRequest(objectModel);
319        
320        request.setAttribute(RightManager.CACHE_REQUEST_ATTRIBUTE_NAME, new HashMap<>());
321        
322        Enumeration<String> attrNames = request.getAttributeNames();
323        while (attrNames.hasMoreElements())
324        {
325            String attrName = attrNames.nextElement();
326            Object value = request.getAttribute(attrName);
327            
328            attrs.put(attrName, value);
329        }
330
331        return attrs;
332    }
333
334    private String _escape(String value)
335    {
336        return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;".replaceAll(">", "&gt;"));
337    }
338    
339    /**
340     * Create url to call
341     * @param pluginOrWorkspace the plugin or workspace name
342     * @param relativeUrl the relative url
343     * @param requestParameters the request parameters. Can not be null.
344     * @return the full url
345     */
346    @SuppressWarnings("unchecked")
347    protected String _createUrl(String pluginOrWorkspace, String relativeUrl, Map<String, Object> requestParameters)
348    {
349        StringBuilder url = new StringBuilder();
350        
351        String urlPrefix = _getUrlPrefix(pluginOrWorkspace);
352        url.append(urlPrefix);
353        
354        url.append(_getRelativePath(relativeUrl));
355        
356        int queryIndex = relativeUrl.indexOf("?");
357        
358        if (queryIndex == -1 && !requestParameters.isEmpty())
359        {
360            // no existing parameters in request
361            url.append("?");
362            
363            for (String key : requestParameters.keySet())
364            {
365                Object value = requestParameters.get(key);
366                if (value instanceof List)
367                {
368                    List<Object> valueAsList = (List<Object>) value;
369                    for (Object v : valueAsList)
370                    {
371                        if (v != null)
372                        {
373                            url.append(_buildQueryParameter(key, v));
374                        }
375                    }
376                }
377                else if (value != null)
378                {
379                    url.append(_buildQueryParameter(key, value));
380                }
381            }
382        }
383        else if (queryIndex > 0)
384        {
385            url.append("?");
386            
387            String queryUrl = relativeUrl.substring(queryIndex + 1, relativeUrl.length());
388            String[] queryParameters = queryUrl.split("&");
389            
390            for (String queryParameter : queryParameters)
391            {
392                if (StringUtils.isNotBlank(queryParameter))
393                {
394                    String[] part = queryParameter.split("=");
395                    String key = part[0];
396                    String v = part.length > 1 ? part[1] : "";
397                    try
398                    {
399                        String value = URLDecoder.decode(v, "UTF-8");
400                        url.append(_buildQueryParameter(key, value));
401                        
402                        if (!requestParameters.containsKey(key))
403                        {
404                            requestParameters.put(key, value);
405                        }
406                    }
407                    catch (UnsupportedEncodingException e)
408                    {
409                        getLogger().error("Unsupported encoding for request parameter '" + key + "' and value '" + v + "'", e);
410                    }
411                }
412            }
413        }
414        
415        return url.toString();
416    }
417    
418    private String _getRelativePath(String url)
419    {
420        int beginIndex = url.length() != 0 && url.charAt(0) == '/' ? 1 : 0;
421        int endIndex = url.indexOf("?");
422        return endIndex == -1 ? url.substring(beginIndex) : url.substring(beginIndex, endIndex);
423    }
424    
425    private StringBuilder _buildQueryParameter(String key, Object value)
426    {
427        StringBuilder queryParameter = new StringBuilder();
428        queryParameter.append(key);
429        queryParameter.append("=");
430        queryParameter.append(String.valueOf(value).replaceAll("%", "%25").replaceAll("=", "%3D").replaceAll("&", "%26").replaceAll("\\+", "%2B"));
431        queryParameter.append("&");
432        
433        return queryParameter;
434    }
435    
436    /**
437     * Get the url prefix
438     * @param pluginOrWorkspace the plugin or workspace name
439     * @return the url prefix
440     */
441    protected String _getUrlPrefix (String pluginOrWorkspace)
442    {
443        StringBuffer url = new StringBuffer("cocoon://");
444        if (pluginOrWorkspace != null && !pluginOrWorkspace.startsWith("_"))
445        {
446            url.append("_plugins/");
447            url.append(pluginOrWorkspace);
448            url.append("/");
449        }
450        else if (pluginOrWorkspace != null)
451        {
452            url.append(pluginOrWorkspace);
453            url.append("/");
454        }
455        
456        return url.toString();
457    }
458    
459    /**
460     * Wrap the handler ignore start and end document, but adding a response tag. 
461     */
462    public static class ResponseHandler extends IgnoreRootHandler
463    {
464        private final String _parameterKey;
465        private final ContentHandler _handler;
466        private final String _code;
467        private long _t0;
468        
469        private final List<String> _startedElements;
470        
471        /**
472         * Create the wrapper
473         * @param handler The content handler to wrap
474         * @param parameterKey The id of the response
475         * @param code The status code of the response
476         * @param t0 System time when the process started
477         */
478        public ResponseHandler(ContentHandler handler, String parameterKey, String code, long t0)
479        {
480            super(handler);
481            _handler = handler;
482            _parameterKey = parameterKey;
483            _code = code;
484            _t0 = t0;
485            _startedElements = new ArrayList<>();
486        }
487        
488        /**
489         * Finish abruptly this handler to obtain a correct XML
490         * @throws SAXException if an error occurred
491         */
492        public void exceptionFinish() throws SAXException
493        {
494            while (_startedElements.size() > 0)
495            {
496                XMLUtils.endElement(_handler, _startedElements.get(_startedElements.size() - 1));
497                _startedElements.remove(_startedElements.size() - 1);
498            }
499        }
500        
501        @Override
502        public void startDocument() throws SAXException
503        {
504            super.startDocument();
505            
506            long t1 = System.currentTimeMillis();
507            
508            AttributesImpl attrs = new AttributesImpl();
509            attrs.addCDATAAttribute("id", _parameterKey);
510            attrs.addCDATAAttribute("code", _code);
511            attrs.addCDATAAttribute("duration", Long.toString(t1 - _t0));
512            XMLUtils.startElement(_handler, "response", attrs);
513
514            _startedElements.add("response");
515        }
516        
517        @Override
518        public void startElement(String uri, String loc, String raw, Attributes a) throws SAXException
519        {
520            super.startElement(uri, loc, raw, a);
521            _startedElements.add(loc);
522        }
523
524        @Override
525        public void endElement(String uri, String loc, String raw) throws SAXException
526        {
527            super.endElement(uri, loc, raw);
528            
529            if (!StringUtils.equals(_startedElements.get(_startedElements.size() - 1), loc))
530            {
531                throw new SAXException("Sax events are not consistents. Cannot close <" + loc + "> while it should be <" + _startedElements.get(_startedElements.size() - 1) + ">");
532            }
533            
534            _startedElements.remove(_startedElements.size() - 1);
535        }
536        
537        @Override
538        public void endDocument() throws SAXException
539        {
540            XMLUtils.endElement(_handler, "response");
541            
542            if (_startedElements.size() != 1)
543            {
544                throw new SAXException("Sax events are not consistents. Remaining " + _startedElements.size() + " events (should be one).");
545            }
546            _startedElements.remove(_startedElements.size() - 1);
547            super.endDocument();
548        }
549    }
550}