001/*
002 *  Copyright 2016 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.plugins.workspaces.requests;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.Arrays;
021import java.util.Enumeration;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Map.Entry;
026import java.util.function.Function;
027import java.util.stream.Collectors;
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.lang3.StringUtils;
041import org.apache.commons.lang3.exception.ExceptionUtils;
042import org.apache.excalibur.source.Source;
043import org.apache.excalibur.xml.sax.SAXParser;
044import org.xml.sax.InputSource;
045import org.xml.sax.SAXException;
046
047import org.ametys.core.authentication.AuthenticateAction;
048import org.ametys.core.ui.dispatcher.DispatchGenerator;
049import org.ametys.core.ui.dispatcher.DispatchGenerator.ResponseHandler;
050import org.ametys.core.ui.dispatcher.DispatchProcessExtensionPoint;
051import org.ametys.core.ui.dispatcher.DispatchRequestProcess;
052import org.ametys.core.user.CurrentUserProvider;
053import org.ametys.core.util.JSONUtils;
054import org.ametys.core.util.URIUtils;
055import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
056import org.ametys.runtime.authentication.AccessDeniedException;
057import org.ametys.runtime.workspace.WorkspaceMatcher;
058
059/**
060 * The purpose of this action is to handle front office requests.
061 * These requests are usually AJAX requests coming from services.
062 * The processing is loosely based on the {@link DispatchGenerator}
063 */
064public class HandleWorkspacesFoRequestGenerator extends ServiceableGenerator
065{
066    /** Dispatch process EP */
067    private DispatchProcessExtensionPoint _dispatchProcessExtensionPoint;
068    
069    /** JSON Utils */
070    private JSONUtils _jsonUtils;
071
072    private CurrentUserProvider _currentUserProvider;
073    
074    @Override
075    public void service(ServiceManager smanager) throws ServiceException
076    {
077        super.service(smanager);
078        _dispatchProcessExtensionPoint = (DispatchProcessExtensionPoint) manager.lookup(DispatchProcessExtensionPoint.ROLE);
079        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
080        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
081    }
082    
083    @Override
084    public void generate() throws IOException, SAXException, ProcessingException
085    {
086        Request request = ObjectModelHelper.getRequest(objectModel);
087        
088        Map<String, Object> reqParameters = _jsonUtils.convertJsonToMap(request.getParameter("content"));
089        Map<String, Object> reqContext = _jsonUtils.convertJsonToMap(request.getParameter("context.parameters"));
090        Map<String, Object> uploadedFiles = _extractFiles(request);
091        
092        contentHandler.startDocument();
093        
094        if (_currentUserProvider.getUser() != null)
095        {
096            // Save request attributes
097            Map<String, Object> savedReqAttributes = _saveRequestAttributes(request);
098            
099            // WORKSPACES-147 Possible WorkspacesFoRequestGenerator request attributes
100            // FIXME Cannot remove request attributes, because necessary FO attributes like Web:FrontOffice:UserIdentity will be removed.
101            // _removeRequestAttributes();
102            
103            for (String extension : _dispatchProcessExtensionPoint.getExtensionsIds())
104            {
105                DispatchRequestProcess processor = _dispatchProcessExtensionPoint.getExtension(extension);
106                processor.preProcess(ObjectModelHelper.getRequest(objectModel));
107            }
108            
109            reqContext.putAll(_transmitAttributes(savedReqAttributes));
110            _setContextInRequestAttributes(request, reqContext);
111            
112            String pluginOrWorkspace = (String) reqParameters.get("pluginOrWorkspace");
113            String relativeUrl = (String) reqParameters.get("url");
114            String responseType = (String) reqParameters.get("responseType");
115          
116            @SuppressWarnings("unchecked")
117            Map<String, Object> requestParameters = (Map<String, Object>) reqParameters.get("parameters");
118            
119            // add possible uploaded files to requestParameters
120            if (requestParameters != null)
121            {
122                @SuppressWarnings("unchecked")
123                List<Object> callableParameters = (List) requestParameters.get("parameters");
124                
125                if (callableParameters != null)
126                {
127                    // Replace file parameters by real files
128                    for (Entry<String, Object> entry : uploadedFiles.entrySet())
129                    {
130                        int parameterIndex = Integer.parseInt(StringUtils.substringAfter(entry.getKey(), "file-"));
131                        
132                        callableParameters.remove(parameterIndex);
133                        callableParameters.add(parameterIndex, entry.getValue());
134                    }
135                }
136            }
137            
138            Source response = null;
139            ResponseHandler responseHandler = null;
140            
141            String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
142            
143            try
144            {
145                String url = _createUrl(pluginOrWorkspace, relativeUrl, requestParameters != null ? requestParameters : new HashMap<String, Object>());
146                
147                if (getLogger().isInfoEnabled())
148                {
149                    getLogger().info(String.format("Resolving front end request with url '%s'", url));
150                }
151                
152                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, "default");
153                
154                response = resolver.resolveURI(url, null, requestParameters);
155                
156                // Workaround - use 0 as parameter key to reuse the ResponseHandler.
157                responseHandler = new ResponseHandler(contentHandler, "0", "200");
158                
159                SAXParser saxParser = null;
160                try (InputStream is = response.getInputStream())
161                {
162                    if ("xml".equalsIgnoreCase(responseType))
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                    else 
169                    {
170                        responseHandler.startDocument();
171                        
172                        String data = IOUtils.toString(is, "UTF-8");
173                        if ("xml2text".equalsIgnoreCase(responseType))
174                        {
175                            // removing xml prolog and surrounding 'text' tag
176                            data = data.substring(data.indexOf(">", data.indexOf("?>") + 2) + 1, data.lastIndexOf("<"));
177                        }
178                        XMLUtils.data(responseHandler, data);
179                        
180                        responseHandler.endDocument();
181                    }
182                }
183                finally
184                {
185                    manager.release(saxParser);
186                }
187            }
188            catch (Throwable e)
189            {
190                String message = String.format("Can not dispatch FO request : '%s' '%s' '%s'", pluginOrWorkspace, relativeUrl, requestParameters);
191                
192                // Ensure SAXException are unrolled the right way
193                getLogger().error(message, new LocatedException(message, e));
194                
195                Throwable t = _unroll(e);
196                
197                String code = "500";
198                if (t instanceof ResourceNotFoundException || t.toString().startsWith("org.apache.cocoon.ResourceNotFoundException:"))
199                {
200                    code = "404";
201                }
202                // Specific workspaces case, where callable are restricted to workspaces plugin
203                if (t instanceof AccessDeniedException)
204                {
205                    code = "403";
206                }
207                
208                AttributesImpl attrs = new AttributesImpl();
209                attrs.addCDATAAttribute("id", "0");
210                attrs.addCDATAAttribute("code", code);
211                
212                String exceptionMessage = t.getMessage();
213                
214                XMLUtils.startElement(contentHandler, "response", attrs);
215                XMLUtils.createElement(contentHandler, "message", _escape(exceptionMessage != null ? exceptionMessage : ""));
216                XMLUtils.createElement(contentHandler, "stacktrace", _escape(ExceptionUtils.getStackTrace(t)));
217                XMLUtils.endElement(contentHandler, "response");
218            }
219            finally
220            {
221                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
222                resolver.release(response);
223                for (String extension : _dispatchProcessExtensionPoint.getExtensionsIds())
224                {
225                    DispatchRequestProcess processor = _dispatchProcessExtensionPoint.getExtension(extension);
226                    processor.postProcess(ObjectModelHelper.getRequest(objectModel));
227                }
228                
229                // Restore initial request attributes
230                _restoreRequestAttributes(request, savedReqAttributes);
231            }
232        }
233        else
234        {
235            XMLUtils.createElement(contentHandler, "NotConnected");
236        }
237        
238        contentHandler.endDocument();
239    }
240    
241    private Throwable _unroll(Throwable initial)
242    {
243        Throwable t = initial;
244        while (t.getCause() != null || t instanceof SAXException && ((SAXException) t).getException() != null)
245        {
246            if (t instanceof SAXException)
247            {
248                t = ((SAXException) t).getException();
249            }
250            else
251            {
252                t = t.getCause();
253            }
254        }
255        
256        return t;
257    }
258    
259    private Map<String, Object> _extractFiles(Request request)
260    {
261        Map<String, Object> files = new HashMap<>();
262        
263        // Extract the optional list of uploaded files
264        Enumeration paramNames = request.getParameterNames();
265        while (paramNames.hasMoreElements())
266        {
267            String paramName = (String) paramNames.nextElement();
268            if (paramName.startsWith("file-"))
269            {
270                files.put(paramName, request.get(paramName));
271            }
272        }
273        
274        return files;
275    }
276    
277    private void _setContextInRequestAttributes(Request request, Map<String, Object> reqContext)
278    {
279        reqContext.forEach((name, value) -> request.setAttribute(name, value));
280    }
281    
282    private Map<String, Object> _transmitAttributes(Map<String, Object> attributes)
283    {
284        List<String> attributesToTransmit = Arrays.asList(
285            AuthenticateAction.REQUEST_ATTRIBUTE_AUTHENTICATED,
286            WorkspaceMatcher.IN_WORKSPACE_URL,
287            WorkspaceMatcher.WORKSPACE_NAME,
288            WorkspaceMatcher.WORKSPACE_THEME,
289            WorkspaceMatcher.WORKSPACE_THEME_URL,
290            WorkspaceMatcher.WORKSPACE_URI
291        );
292        
293        // mapping each attributesToTransmit entry to (entry, value of entry in attributes)
294        return attributesToTransmit.stream().collect(Collectors.toMap(Function.identity(), attributes::get));
295    }
296    
297    /**
298     * Transforms the request attributes into a map and clean the attributes
299     * @param request The request
300     * @return A copy of all the request attributes
301     */
302    private Map<String, Object> _saveRequestAttributes(Request request)
303    {
304        Map<String, Object> attrs = new HashMap<>();
305        
306        Enumeration<String> attrNames = request.getAttributeNames();
307        
308        while (attrNames.hasMoreElements())
309        {
310            String attrName = attrNames.nextElement();
311            attrs.put(attrName, request.getAttribute(attrName));
312        }
313        
314        return attrs;
315    }
316    
317    /**
318     * Clean the requests attributes and add those in the map
319     * @param request The request 
320     * @param attributes The attributes to restore
321     */
322    private void _restoreRequestAttributes(Request request, Map<String, Object> attributes)
323    {
324        _removeRequestAttributes();
325        
326        for (String attrName : attributes.keySet())
327        {
328            request.setAttribute(attrName, attributes.get(attrName));
329        }
330    }
331    
332    private void _removeRequestAttributes()
333    {
334        Request request = ObjectModelHelper.getRequest(objectModel);
335        Enumeration<String> attrNames = request.getAttributeNames();
336        
337        while (attrNames.hasMoreElements())
338        {
339            String attrName = attrNames.nextElement();
340            request.removeAttribute(attrName);
341        }
342    }
343    
344    private String _escape(String value)
345    {
346        return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;".replaceAll(">", "&gt;"));
347    }
348    
349    /**
350     * Create url to call
351     * @param pluginOrWorkspace the plugin or workspace name
352     * @param relativeUrl the relative url
353     * @param requestParameters the request parameters. Can not be null.
354     * @return the full url
355     */
356    protected String _createUrl(String pluginOrWorkspace, String relativeUrl, Map<String, Object> requestParameters)
357    {
358        StringBuilder url = new StringBuilder();
359        
360        String urlPrefix = _getUrlPrefix(pluginOrWorkspace);
361        url.append(urlPrefix);
362        
363        url.append(_getRelativePath(relativeUrl));
364        
365        int queryIndex = relativeUrl.indexOf("?");
366        
367        if (queryIndex == -1 && !requestParameters.isEmpty())
368        {
369            // no existing parameters in request
370            url.append("?");
371            
372            for (String key : requestParameters.keySet())
373            {
374                Object value = requestParameters.get(key);
375                if (value instanceof List)
376                {
377                    @SuppressWarnings("unchecked")
378                    List<Object> valueAsList = (List<Object>) value;
379                    for (Object v : valueAsList)
380                    {
381                        if (v != null)
382                        {
383                            url.append(_buildQueryParameter(key, v));
384                        }
385                    }
386                }
387                else if (value != null)
388                {
389                    url.append(_buildQueryParameter(key, value));
390                }
391            }
392        }
393        else if (queryIndex > 0)
394        {
395            url.append("?");
396            
397            String queryUrl = relativeUrl.substring(queryIndex + 1, relativeUrl.length());
398            String[] queryParameters = queryUrl.split("&");
399            
400            for (String queryParameter : queryParameters)
401            {
402                if (StringUtils.isNotBlank(queryParameter))
403                {
404                    String[] part = queryParameter.split("=");
405                    String key = part[0];
406                    String v = part.length > 1 ? part[1] : "";
407                    
408                    String value = URIUtils.decode(v);
409                    url.append(_buildQueryParameter(key, value));
410                    
411                    if (!requestParameters.containsKey(key))
412                    {
413                        requestParameters.put(key, value);
414                    }
415                }
416            }
417        }
418        
419        return url.toString();
420    }
421    
422    private StringBuilder _buildQueryParameter(String key, Object value)
423    {
424        StringBuilder queryParameter = new StringBuilder();
425        queryParameter.append(key);
426        queryParameter.append("=");
427        queryParameter.append(String.valueOf(value).replaceAll("%", "%25").replaceAll("=", "%3D").replaceAll("&", "%26").replaceAll("\\+", "%2B"));
428        queryParameter.append("&");
429        
430        return queryParameter;
431    }
432    
433    /**
434     * Get the url prefix
435     * @param pluginOrWorkspace the plugin or workspace name
436     * @return the url prefix
437     */
438    protected String _getUrlPrefix (String pluginOrWorkspace)
439    {
440        StringBuffer url = new StringBuffer("cocoon://");
441        
442        if (!StringUtils.startsWith(pluginOrWorkspace, "_"))
443        {
444            url.append("_plugins/");
445        }
446        
447        if (StringUtils.isNotEmpty(pluginOrWorkspace))
448        {
449            url.append(pluginOrWorkspace).append("/");
450        }
451        
452        return url.toString();
453    }
454    
455    private String _getRelativePath(String url)
456    {
457        int beginIndex = StringUtils.startsWith(url, "/") ? 1 : 0;
458        int endIndex = StringUtils.indexOf(url, "?");
459        return StringUtils.substring(url, beginIndex, endIndex >= 0 ? endIndex : url.length());
460    }
461}