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}