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