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