001/*
002 *  Copyright 2011 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.site;
017
018import java.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.UnsupportedEncodingException;
022import java.net.URI;
023import java.time.ZonedDateTime;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Enumeration;
027import java.util.HashSet;
028import java.util.List;
029import java.util.Map;
030import java.util.Set;
031import java.util.Vector;
032import java.util.regex.Pattern;
033
034import javax.servlet.http.HttpServletRequest;
035
036import org.apache.cocoon.environment.ObjectModelHelper;
037import org.apache.cocoon.environment.Request;
038import org.apache.cocoon.environment.Session;
039import org.apache.cocoon.environment.http.HttpEnvironment;
040import org.apache.cocoon.servlet.multipart.Part;
041import org.apache.commons.io.IOUtils;
042import org.apache.commons.lang.StringUtils;
043import org.apache.http.Consts;
044import org.apache.http.HttpEntity;
045import org.apache.http.HttpResponse;
046import org.apache.http.NameValuePair;
047import org.apache.http.client.config.RequestConfig;
048import org.apache.http.client.entity.UrlEncodedFormEntity;
049import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
050import org.apache.http.client.methods.HttpGet;
051import org.apache.http.client.methods.HttpHead;
052import org.apache.http.client.methods.HttpOptions;
053import org.apache.http.client.methods.HttpPost;
054import org.apache.http.client.methods.HttpPut;
055import org.apache.http.client.methods.HttpRequestBase;
056import org.apache.http.client.methods.HttpUriRequest;
057import org.apache.http.entity.ContentType;
058import org.apache.http.entity.InputStreamEntity;
059import org.apache.http.entity.mime.HttpMultipartMode;
060import org.apache.http.entity.mime.MultipartEntityBuilder;
061import org.apache.http.entity.mime.content.InputStreamBody;
062import org.apache.http.entity.mime.content.StringBody;
063import org.apache.http.impl.client.CloseableHttpClient;
064import org.apache.http.impl.client.HttpClientBuilder;
065import org.apache.http.message.BasicNameValuePair;
066
067import org.ametys.core.authentication.AuthenticateAction;
068import org.ametys.core.authentication.CredentialProvider;
069import org.ametys.core.user.UserIdentity;
070import org.ametys.core.util.URIUtils;
071import org.ametys.plugins.site.Site;
072import org.ametys.plugins.site.SiteUrl;
073import org.ametys.plugins.site.proxy.BackOfficeRequestProxy;
074import org.ametys.plugins.site.proxy.BackOfficeRequestProxyExtensionPoint;
075import org.ametys.runtime.config.Config;
076import org.ametys.runtime.exception.ServiceUnavailableException;
077import org.ametys.runtime.servlet.RuntimeServlet;
078import org.ametys.runtime.servlet.RuntimeServlet.MaintenanceStatus;
079import org.ametys.runtime.servlet.RuntimeServlet.RunMode;
080
081/**
082 * Helper class that builds the request the front-office makes to the back-office to query a page or a resource.
083 */
084public final class BackOfficeRequestHelper
085{
086    private static final Pattern __AUTHORIZED_HEADERS = Pattern.compile("^(?:Accept|Accept-Language|Accept-Charset|Referer|Origin|Range|User-Agent|If-None-Match|If-Modified-Since)$", Pattern.CASE_INSENSITIVE);
087    
088    private static final Set<String> __FILTERED_REQUEST_PARAMETERS = new HashSet<>(Arrays.asList("cocoon-view"));
089 
090    private static final String __BO_TIMEOUT_CONFIG = "org.ametys.site.bo.request.timeout";
091    
092    private BackOfficeRequestHelper()
093    {
094        // Helper class.
095    }
096    
097    /**
098     * Build a HttpClient object parametrized
099     * @return The httpclient object
100     * @throws ServiceUnavailableException If the server is in maintenance
101     */
102    public static CloseableHttpClient getHttpClient()
103    {
104        if (RuntimeServlet.getRunMode() == RunMode.MAINTENANCE)
105        {
106            throw new ServiceUnavailableException();
107        }
108        
109        int timeout = Math.max(0, ((Long) Config.getInstance().getValue(__BO_TIMEOUT_CONFIG)).intValue());
110        
111        RequestConfig config = RequestConfig.custom()
112                .setConnectTimeout(timeout * 1000)
113                .setConnectionRequestTimeout(timeout * 1000)
114                .setSocketTimeout(timeout * 1000).build();
115        
116        CloseableHttpClient httpClient = HttpClientBuilder.create()
117            .setDefaultRequestConfig(config)
118            .disableRedirectHandling()
119            .useSystemProperties()
120            .build();
121        
122        return httpClient;
123    }
124    
125    /**
126     * When the response is a 503 code from the BO, we may switch to maintenance mode if the BO is really in maintenance mode
127     * @param cmsResponse The BO response
128     */
129    public static void switchOnMaintenanceIfNeeded(HttpResponse cmsResponse)
130    {
131        if (cmsResponse.getStatusLine().getStatusCode() == 503 
132            && cmsResponse.getFirstHeader("X-Ametys-Maintenance") != null)
133        {
134            // Back-office is in maintenance, let's switch
135            if (RuntimeServlet.getRunMode() == RunMode.NORMAL)
136            {
137                RuntimeServlet.setMaintenanceStatus(MaintenanceStatus.FORCED, new RuntimeServlet.ForcedMainteanceInformations(null, null, ZonedDateTime.now()));
138                RuntimeServlet.setRunMode(RunMode.MAINTENANCE);
139            }
140        }
141    }
142    
143    /**
144     * Build a HttpClient request object that will be sent to the back-office to query the page.
145     * @param objectModel the current object model.
146     * @param page the wanted page path.
147     * @param requestProxyExtensionPoint The extension point for adding request headers in BO request
148     * @return the HttpClient request, to be sent to the back-office.
149     * @throws IOException if an error occurs building the request.
150     */
151    public static HttpUriRequest getRequest(Map objectModel, String page, BackOfficeRequestProxyExtensionPoint requestProxyExtensionPoint) throws IOException
152    {
153        Request request = ObjectModelHelper.getRequest(objectModel);
154        
155        String cmsURL = Config.getInstance().getValue("org.ametys.site.bo");
156        String baseUrl = cmsURL + "/generate/" + URIUtils.encodePath(page);
157        
158        String method = request.getMethod();
159        
160        SiteUrl url = (SiteUrl)  request.getAttribute("url");
161        String baseServerPath = url.getBaseServerPath(request);
162        
163        HttpUriRequest boRequest = _getRequest(objectModel, request, method, url, baseServerPath, baseUrl);
164        
165        _addRequestHeaders(request, boRequest, requestProxyExtensionPoint);
166        _copyCookieHeaders(request, boRequest);
167        
168        return boRequest;
169    }
170
171    private static HttpUriRequest _getRequest(Map objectModel, Request request, String method, SiteUrl url, String baseServerPath, String baseUrl) throws IOException, UnsupportedEncodingException
172    {
173        HttpUriRequest boRequest = null;
174        switch (method)
175        {
176            case "GET":
177            case "HEAD":
178            case "OPTIONS":
179            case "UNLOCK":
180                boRequest = _getGetHeadOptionsOrUnlockRequest(request, method, url, baseServerPath, baseUrl);
181                break;
182            case "PUT":
183            case "LOCK":
184            case "PROPFIND":
185            case "MKCOL":
186                boRequest = _getPutLockPropfindOrMkcolRequest(objectModel, request, method, url, baseServerPath, baseUrl);
187                break;
188            case "POST":
189                boRequest = _getPostRequest(objectModel, request, url, baseServerPath, baseUrl);
190                break;
191            default:
192                throw new IllegalArgumentException("Unrecognized method " + method);
193        }
194        return boRequest;
195    }
196
197    private static HttpUriRequest _getPostRequest(Map objectModel, Request request, SiteUrl url, String baseServerPath, String baseUrl) throws IOException, UnsupportedEncodingException
198    {
199        HttpUriRequest boRequest;
200        HttpServletRequest postReq = (HttpServletRequest) objectModel.get(HttpEnvironment.HTTP_REQUEST_OBJECT);
201        InputStream postBody = postReq.getInputStream();
202        byte[] postBytes = IOUtils.toByteArray(postBody);
203        
204        boolean hasBody = postBytes.length > 0;
205        
206        String postUri = baseUrl;
207        
208        if (hasBody)
209        {
210            // in case of a body in the original request, we have to copy this body, and put the two parameters _contextPath and _baseServerPath as query string
211            postUri += "?" + _getContextQueryPart(url, baseServerPath) + "&" + _getEditionQueryPart(request);
212        }
213        else
214        {
215            postUri += "?" + _getEditionQueryPart(request);
216        }
217        
218        HttpPost postRequest = new HttpPost(postUri);
219        
220        HttpEntity postEntity = null;
221        
222        String postContentType = request.getContentType();
223        if (postContentType != null && postContentType.toLowerCase().indexOf("multipart/form-data") > -1)
224        {
225            // multipart request
226            MultipartEntityBuilder multipartBuilder = _getMultipartEntityBuilder(request);
227            
228            multipartBuilder.addPart("_contextPath", new StringBody(url.getServerPath(), ContentType.create("text/plain", Consts.UTF_8)));
229            multipartBuilder.addPart("_baseServerPath", new StringBody(baseServerPath, ContentType.create("text/plain", Consts.UTF_8)));
230            
231            postEntity = multipartBuilder.build();
232        }
233        else if (hasBody)
234        {
235            postRequest.setHeader("Content-Type", postContentType);
236            postEntity = new InputStreamEntity(new ByteArrayInputStream(postBytes), postBytes.length);
237        }
238        else
239        {
240            // url encoded body
241            List<NameValuePair> params = new ArrayList<>();
242            
243            params.add(new BasicNameValuePair("_contextPath", url.getServerPath()));
244            params.add(new BasicNameValuePair("_baseServerPath", baseServerPath));
245            
246            Enumeration<String> names = request.getParameterNames();
247            while (names.hasMoreElements())
248            {
249                String paramName = names.nextElement();
250                if (!__FILTERED_REQUEST_PARAMETERS.contains(paramName))
251                {
252                    for (String value : request.getParameterValues(paramName))
253                    {
254                        params.add(new BasicNameValuePair(paramName, value));
255                    }
256                }
257            }
258            
259            postEntity = new UrlEncodedFormEntity(params, "UTF-8");
260        }
261        
262        postRequest.setEntity(postEntity);
263        
264        boRequest = postRequest;
265        return boRequest;
266    }
267
268    private static HttpUriRequest _getPutLockPropfindOrMkcolRequest(Map objectModel, Request request, String method, SiteUrl url, String baseServerPath, String baseUrl) throws IOException
269    {
270        String uri = baseUrl + "?" + _getContextQueryPart(url, baseServerPath) + "&" + _getEditionQueryPart(request);
271        
272        HttpEntityEnclosingRequestBase newRequest = null;
273        
274        if ("PUT".equals(method))
275        {
276            newRequest = new HttpPut(uri);
277        }
278        else if ("LOCK".equals(method))
279        {
280            newRequest = new HttpLock(uri);
281        }
282        else if ("PROPFIND".equals(method))
283        {
284            newRequest = new HttpPropfind(uri);
285        }
286        else if ("MKCOL".equals(method))
287        {
288            newRequest = new HttpMkcol(uri);
289        }
290
291        assert newRequest != null;
292        
293        String contentType = request.getContentType();
294        if (contentType != null)
295        {
296            newRequest.setHeader("Content-Type", contentType);
297        }
298        
299        HttpServletRequest req = (HttpServletRequest) objectModel.get(HttpEnvironment.HTTP_REQUEST_OBJECT);
300        InputStream body = req.getInputStream();
301        byte[] bytes = IOUtils.toByteArray(body);
302        
303        HttpEntity entity = new InputStreamEntity(new ByteArrayInputStream(bytes), bytes.length);
304        newRequest.setEntity(entity);
305        
306        return newRequest;
307    }
308
309    private static HttpUriRequest _getGetHeadOptionsOrUnlockRequest(Request request, String method, SiteUrl url, String baseServerPath, String baseUrl)
310    {
311        String boUrl = baseUrl + "?" + _getContextQueryPart(url, baseServerPath) 
312                               + "&_initialRequest=" + URIUtils.encodeParameter("/" + URIUtils.encodePath((String) request.getAttribute("path")) + (StringUtils.isEmpty(request.getQueryString()) ? "" : "?" + request.getQueryString())) + _getParameters(request) 
313                               + "&" + _getEditionQueryPart(request);
314        
315        if ("GET".equals(method))
316        {
317            return new HttpGet(boUrl);
318        }
319        else if ("HEAD".equals(method))
320        {
321            return new HttpHead(boUrl);
322        }
323        else if ("OPTIONS".equals(method))
324        {
325            return new HttpOptions(boUrl);
326        }
327        else // if ("UNLOCK".equals(method))
328        {
329            return new HttpUnLock(boUrl);
330        }
331    }
332    
333    private static String _getEditionQueryPart(Request request)
334    {
335        String editionMode = (String) request.getAttribute(GetSiteAction.EDITION_URI);
336        return "true".equals(editionMode) ? "_" + GetSiteAction.EDITION_URI + "=true" : "";
337    }
338    
339    private static String _getContextQueryPart(SiteUrl url, String baseServerPath)
340    {
341        return "_contextPath=" + url.getServerPath() + "&_baseServerPath=" + baseServerPath;
342    }
343    
344    /**
345     * Get the front-office request's parameters as an HttpClient MultipartEntity,
346     * to be added to a POST back-office request.
347     * @param request the front-office request.
348     * @return the parameters encoded in a multipart entity.
349     * @throws IOException if an error occurs extracting the parameters or building the entity.
350     */
351    private static MultipartEntityBuilder _getMultipartEntityBuilder(Request request) throws IOException
352    {
353        MultipartEntityBuilder builder = MultipartEntityBuilder.create();
354        builder.setMode(HttpMultipartMode.RFC6532);
355        
356        Enumeration<String> names = request.getParameterNames();
357        
358        while (names.hasMoreElements())
359        {
360            String paramName = names.nextElement();
361            
362            if (!__FILTERED_REQUEST_PARAMETERS.contains(paramName))
363            {
364                Object value = request.get(paramName);
365                _addMultipartEntityToBuilder(builder, paramName, value);
366            }
367        }
368        
369        return builder;
370    }
371
372    private static void _addMultipartEntityToBuilder(MultipartEntityBuilder builder, String paramName, Object value) throws IOException
373    {
374        if (value instanceof Part)
375        {
376            Part part = (Part) value;
377            builder.addPart(paramName, new InputStreamBody(part.getInputStream(), ContentType.create(part.getMimeType()), part.getFileName()));
378        }
379        else if (value instanceof Vector)
380        {
381            for (Object v : (Vector) value)
382            {
383                _addMultipartEntityToBuilder(builder, paramName, v);
384            }
385        }
386        else
387        {
388            builder.addPart(paramName, new StringBody(value.toString(), ContentType.create("text/plain", Consts.UTF_8)));
389        }
390    }
391    
392    /**
393     * Get the front-office request's parameters as a String, to be added to
394     * a GET back-office request.
395     * @param request the front-office request.
396     * @return the parameters as a String.
397     */
398    private static String _getParameters(Request request)
399    {
400        StringBuilder params = new StringBuilder();
401        
402        Enumeration<String> names = request.getParameterNames();
403        while (names.hasMoreElements())
404        {
405            String paramName = names.nextElement();
406            if (!__FILTERED_REQUEST_PARAMETERS.contains(paramName))
407            {
408                for (String value : request.getParameterValues(paramName))
409                {
410                    params.append("&");
411                    params.append(paramName);
412                    params.append("=");
413                    params.append(URIUtils.encodeParameter(value));
414                }
415            }
416        }
417        
418        return params.toString();
419    }
420    
421    /**
422     * Add headers indicating this is a request from the front-office to the back-office,
423     * specifying the user if applicable.
424     * @param request the front-office request.
425     * @param boRequest the request object to be sent to the back-office.
426     * @param requestProxyExtensionPoint The extension point for adding request headers in BO request
427     */
428    private static void _addRequestHeaders(Request request, HttpUriRequest boRequest, BackOfficeRequestProxyExtensionPoint requestProxyExtensionPoint)
429    {
430        // Get the user, if in session.
431        UserIdentity user = FrontAuthenticateAction.getUserIdentityFromSession(request);
432        
433        // Add Ametys headers.
434        boRequest.addHeader("X-Ametys-FO", "true");
435        if (user != null)
436        {
437            boRequest.addHeader("X-Ametys-FO-Login", user.getLogin());
438            boRequest.addHeader("X-Ametys-FO-Population", user.getPopulationId());
439            
440            Site site = (Site) request.getAttribute("site");
441            Session session = request.getSession(false);
442            if (site != null && session != null)
443            {
444                CredentialProvider credentialProvider = (CredentialProvider) session.getAttribute("Runtime:CredentialProvider-" + site.getName());
445                boRequest.addHeader("X-Ametys-FO-Credential-Provider", credentialProvider.getId());
446            }
447            
448        }
449        
450        for (String requestId : requestProxyExtensionPoint.getExtensionsIds())
451        {
452            BackOfficeRequestProxy boRequestComponent = requestProxyExtensionPoint.getExtension(requestId);
453            boRequestComponent.prepareBackOfficeRequest(request, boRequest);
454        }
455        
456        // Add apache unique-id
457        String uuid = (String) request.getAttribute("Monitoring-UUID");
458        if (uuid != null)
459        {
460            boRequest.addHeader("X-Ametys-FO-UUID", uuid);
461        }
462        
463        // Forwarding headers
464        Enumeration<String> headers = request.getHeaderNames();
465        while (headers.hasMoreElements())
466        {
467            String headerName = headers.nextElement();
468            if (__AUTHORIZED_HEADERS.matcher(headerName).matches())
469            {
470                // forward
471                Enumeration<String> headerValues = request.getHeaders(headerName);
472                while (headerValues.hasMoreElements())
473                {
474                    String headerValue = headerValues.nextElement();
475                    boRequest.addHeader(headerName, headerValue);
476                }
477            }
478        }
479        
480        // Add X-Forwarded-For
481        String xff = request.getHeader("X-Forwarded-For");
482        String remoteIP = request.getRemoteAddr();
483        
484        String newXFF = (xff == null ? "" : xff + ", ") + remoteIP;
485        boRequest.setHeader("X-Forwarded-For", newXFF);
486        
487        // Token
488        String token = request.getHeader(AuthenticateAction.HEADER_TOKEN);
489        boRequest.setHeader(AuthenticateAction.HEADER_TOKEN, token);
490    }
491    
492    /**
493     * Copy cookie headers that were sent by the client into the request
494     * that will be sent to the back-office.
495     * @param request the front-office request.
496     * @param boRequest the request object to be sent to the back-office.
497     */
498    private static void _copyCookieHeaders(Request request, HttpUriRequest boRequest)
499    {
500        Enumeration<String> cookieHeaders = request.getHeaders("Cookie");
501        while (cookieHeaders.hasMoreElements())
502        {
503            String cookieHeader = cookieHeaders.nextElement();
504            
505            String[] cookiesValue = cookieHeader.split("; ");
506            for (String cookieValue : cookiesValue)
507            {
508                if (cookieValue.startsWith("JSESSIONID="))
509                {
510                    // discard (the BO should not try to attach a session with this id)
511                }
512                else if (cookieValue.startsWith(GeneratePageAction.__BACKOFFICE_JSESSION_ID + "="))
513                {
514                    String modifiedCookieValue = cookieValue.replace(GeneratePageAction.__BACKOFFICE_JSESSION_ID + "=", "JSESSIONID=");
515                    boRequest.addHeader("Cookie", modifiedCookieValue);
516                }
517                else
518                {
519                    boRequest.addHeader("Cookie", cookieValue);
520                }
521            }
522        }
523    }
524    
525    static class HttpLock extends HttpEntityEnclosingRequestBase 
526    {
527        HttpLock(final String uri) 
528        {
529            super();
530            setURI(URI.create(uri));
531        }
532
533        @Override
534        public String getMethod() 
535        {
536            return "LOCK";
537        }
538    }
539    
540    static class HttpUnLock extends HttpRequestBase 
541    {
542        HttpUnLock(final String uri) 
543        {
544            super();
545            setURI(URI.create(uri));
546        }
547
548        @Override
549        public String getMethod() 
550        {
551            return "UNLOCK";
552        }
553    }
554    
555    static class HttpPropfind extends HttpEntityEnclosingRequestBase 
556    {
557        HttpPropfind(final String uri) 
558        {
559            super();
560            setURI(URI.create(uri));
561        }
562
563        @Override
564        public String getMethod() 
565        {
566            return "PROPFIND";
567        }
568    }
569
570    static class HttpMkcol extends HttpEntityEnclosingRequestBase
571    {
572        HttpMkcol(final String uri)
573        {
574            super();
575            setURI(URI.create(uri));
576        }
577
578        @Override
579        public String getMethod()
580        {
581            return "MKCOL";
582        }
583    }
584}