001/* 002 * Copyright 2010 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 */ 016 017package org.ametys.web.cache; 018 019import java.io.ByteArrayInputStream; 020import java.io.IOException; 021import java.io.InputStream; 022import java.net.URI; 023import java.net.URISyntaxException; 024import java.nio.charset.StandardCharsets; 025import java.time.ZonedDateTime; 026import java.util.ArrayList; 027import java.util.HashMap; 028import java.util.List; 029import java.util.Map; 030import java.util.Optional; 031 032import javax.xml.parsers.DocumentBuilder; 033import javax.xml.parsers.DocumentBuilderFactory; 034import javax.xml.parsers.ParserConfigurationException; 035import javax.xml.transform.TransformerException; 036 037import org.apache.avalon.framework.activity.Disposable; 038import org.apache.avalon.framework.activity.Initializable; 039import org.apache.avalon.framework.component.Component; 040import org.apache.avalon.framework.service.ServiceException; 041import org.apache.avalon.framework.service.ServiceManager; 042import org.apache.commons.io.IOUtils; 043import org.apache.commons.lang3.StringUtils; 044import org.apache.commons.lang3.tuple.Pair; 045import org.apache.hc.client5.http.classic.methods.HttpPost; 046import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; 047import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; 048import org.apache.hc.core5.http.ClassicHttpRequest; 049import org.apache.hc.core5.http.ClassicHttpResponse; 050import org.apache.hc.core5.http.Header; 051import org.apache.hc.core5.http.HttpEntity; 052import org.apache.hc.core5.http.HttpException; 053import org.apache.hc.core5.http.message.BasicNameValuePair; 054import org.apache.hc.core5.io.CloseMode; 055import org.apache.xpath.XPathAPI; 056import org.quartz.JobKey; 057import org.quartz.SchedulerException; 058import org.w3c.dom.Document; 059import org.xml.sax.SAXException; 060 061import org.ametys.core.util.HttpUtils; 062import org.ametys.plugins.core.schedule.Scheduler; 063import org.ametys.runtime.config.Config; 064import org.ametys.runtime.plugin.component.AbstractLogEnabled; 065import org.ametys.runtime.plugin.component.DeferredServiceable; 066import org.ametys.web.cache.scheduler.InvalidateRemoteFOCacheRunnable; 067import org.ametys.web.repository.page.Page; 068import org.ametys.web.repository.site.Site; 069import org.ametys.web.repository.site.SiteManager; 070import org.ametys.web.repository.sitemap.Sitemap; 071 072/** 073 * Helper for dealing with front-office cache. 074 */ 075public final class FOCommHelper extends AbstractLogEnabled implements Component, DeferredServiceable, Initializable, Disposable 076{ 077 /** Avalon Role */ 078 public static final String ROLE = FOCommHelper.class.getName(); 079 080 private static Object _lockToken = new Object(); 081 082 private Scheduler _scheduler; 083 private SiteManager _siteManager; 084 085 private Map<String, ZonedDateTime> _nextInvalidateDate = new HashMap<>(); 086 087 private CloseableHttpClient _httpClient; 088 089 public void deferredService(ServiceManager manager) throws ServiceException 090 { 091 _scheduler = (Scheduler) manager.lookup(Scheduler.ROLE); 092 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 093 } 094 095 public void initialize() throws Exception 096 { 097 _httpClient = HttpUtils.createHttpClient(0, 2, false); 098 } 099 100 public void dispose() 101 { 102 _httpClient.close(CloseMode.GRACEFUL); 103 } 104 /** 105 * Request the given URL on each configured front-office. 106 * @param url the url to be called. 107 * @throws Exception if an error occurred. 108 */ 109 public void testWS(String url) throws Exception 110 { 111 testWS(url, null); 112 } 113 114 /** 115 * Request the given URL on each configured front-office. 116 * @param url the url to be called. 117 * @param postParameters submited values 118 * @throws Exception if an error occurred. 119 */ 120 public void testWS(String url, List<Pair<String, String>> postParameters) throws Exception 121 { 122 List<FOResult> results = callWS(url, postParameters); 123 for (FOResult result : results) 124 { 125 if (result.code() == -1) 126 { 127 getLogger().error("Unable to send request '{}' to server '{}'", url, result.uri().getHost(), result.exception()); 128 } 129 else if (_checkResponse(result.body())) 130 { 131 if (getLogger().isDebugEnabled()) 132 { 133 getLogger().debug("Request sent and received successfully to: {}", result.uri().getHost()); 134 } 135 } 136 else 137 { 138 getLogger().error( 139 "An error occured with request '{}' to server '{}', response: {} {}", 140 url, 141 result.uri().getHost(), 142 result.code(), 143 result.reason() 144 ); 145 } 146 } 147 } 148 149 /** 150 * Request the given URL on each configured front-office. 151 * @param url the url to be called. 152 * @return The streams 153 * @throws Exception if an error occurred. 154 */ 155 public List<FOResult> callWS(String url) throws Exception 156 { 157 return callWS(url, null); 158 } 159 160 /** 161 * Get front office applications urls 162 * @return The non null list of url configured. Can be empty. 163 */ 164 public String[] getFrontURLS() 165 { 166 String frontConfig = Config.getInstance().getValue("org.ametys.web.front.url"); 167 String[] frontURLs = StringUtils.split(frontConfig, ","); 168 return frontURLs; 169 } 170 171 /** 172 * Request the given URL on each configured front-office. 173 * @param url the url to be called. 174 * @param postParameters submitted values 175 * @return The streams 176 * @throws Exception if an error occurred. 177 */ 178 public List<FOResult> callWS(String url, List<Pair<String, String>> postParameters) throws Exception 179 { 180 String[] frontURLs = getFrontURLS(); 181 182 List<FOResult> responses = new ArrayList<>(); 183 for (String rawFrontURL : frontURLs) 184 { 185 String frontURL = rawFrontURL.trim(); 186 187 String wsURL = frontURL + url; 188 189 // Prepare a request object 190 HttpPost request = new HttpPost(wsURL); 191 request.addHeader("X-Ametys-BO", "true"); 192 193 if (postParameters != null) 194 { 195 List<BasicNameValuePair> nameValueParameters = postParameters.stream() 196 .map(pair -> new BasicNameValuePair(pair.getLeft(), pair.getRight())) 197 .toList(); 198 request.setEntity(new UrlEncodedFormEntity(nameValueParameters, StandardCharsets.UTF_8)); 199 } 200 201 // Execute the request 202 try 203 { 204 FOResult result = _httpClient.execute(request, response -> { 205 return FOResult.of(request, response); 206 }); 207 208 responses.add(result); 209 } 210 catch (IOException e) 211 { 212 responses.add(FOResult.of(request, e)); 213 } 214 } 215 216 return responses; 217 } 218 219 /** 220 * Result of a request to a front-office 221 * @param uri the requested URI (FO URI + path) 222 * @param code the HTTP response code, -1 if the request failed. 223 * @param reason the HTTP response reason phrase, can be null. 224 * @param body the HTTP response body, can be null. 225 * @param exception the exception that occurred in case of failure to communicate with the remote server, null in case of success 226 */ 227 public record FOResult(URI uri, int code, String reason, byte[] body, Throwable exception) { 228 /** 229 * Build a {@link FOResult} from a request and response object 230 * @param request the HTTP request object 231 * @param response the HTTP response object 232 * @return an {@link FOResult} describing the request and response 233 * @throws HttpException if an error occurs while handling the response 234 * @throws IOException if an error occurs while reading the response 235 */ 236 protected static FOResult of(ClassicHttpRequest request, ClassicHttpResponse response) throws HttpException, IOException 237 { 238 try 239 { 240 return new FOResult(request.getUri(), response.getCode(), response.getReasonPhrase(), _getResponse(response), null); 241 } 242 catch (URISyntaxException e) 243 { 244 // An invalid URI would have failed before the response handling but anyway just wrap it 245 throw new HttpException("An error occurred while trying to handle the result", e); 246 } 247 } 248 249 /** 250 * Build a {@link FOResult} representing a failed request a request 251 * @param request the HTTP request object 252 * @param e the exception that occurred 253 * @return an {@link FOResult} describing the request and failed response 254 * @throws HttpException if an error occurs while handling the response 255 */ 256 257 public static FOResult of(ClassicHttpRequest request, Throwable e) throws HttpException 258 { 259 try 260 { 261 return new FOResult(request.getUri(), -1, null, null, e); 262 } 263 catch (URISyntaxException e1) 264 { 265 // An invalid URI would have failed before the response handling but anyway just wrap it 266 throw new HttpException("An error occurred while trying to handle the result", e1); 267 } 268 } 269 } 270 271 private boolean _checkResponse(byte[] bodyResponse) throws ParserConfigurationException, IllegalStateException, IOException, SAXException, TransformerException 272 { 273 if (bodyResponse == null) 274 { 275 return false; 276 } 277 278 try (InputStream is = new ByteArrayInputStream(bodyResponse)) 279 { 280 DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); 281 Document document = docBuilder.parse(is); 282 return XPathAPI.eval(document, "count(/ActionResult)").toString().equals("1"); 283 } 284 } 285 286 private static byte[] _getResponse(ClassicHttpResponse response) throws IOException 287 { 288 if (response.getCode() != 200) 289 { 290 return null; 291 } 292 293 if (response.getFirstHeader("X-Ametys-SafeMode") != null) 294 { 295 // Site application is in safe mode 296 return null; 297 } 298 299 boolean validResponse = Optional.ofNullable(response.getFirstHeader("Content-Type")) 300 .map(Header::getValue) 301 .map(cType -> cType.startsWith("text/xml")) 302 .orElse(false); 303 if (!validResponse) 304 { 305 return null; 306 } 307 308 try (HttpEntity entity = response.getEntity(); 309 InputStream is = entity.getContent()) 310 { 311 return IOUtils.toByteArray(is); 312 } 313 } 314 315 /** 316 * Invalidates the front-office cache, a delay may be apply to comply with the minimum refresh period configured in the site. 317 * @param site the site. 318 * @throws Exception if an error occurs. 319 */ 320 public void invalidateFOCache(Site site) throws Exception 321 { 322 _delayFOCacheInvalidation(site.getName()); 323 } 324 325 /** 326 * Invalidate the site caches without delay. 327 * @param siteName The site name 328 * @throws Exception if an error occurs 329 */ 330 public void invalidateFOCacheImmediately(String siteName) throws Exception 331 { 332 synchronized (_lockToken) 333 { 334 // Update the next invalidation date to now + validity delay 335 long periodOfValidity = _siteManager.getSite(siteName).getValueOrDefault("cache-validity", 0L); 336 _nextInvalidateDate.put(siteName, ZonedDateTime.now().plusSeconds(periodOfValidity)); 337 338 testWS("/_invalidate-site/" + siteName); 339 testWS("/_invalidate-skin/" + siteName); 340 341 // Remove job if exists 342 InvalidateRemoteFOCacheRunnable invalidateFOCacheRunnable = new InvalidateRemoteFOCacheRunnable(siteName); 343 JobKey jobKey = new JobKey(invalidateFOCacheRunnable.getId(), Scheduler.JOB_GROUP); 344 if (_scheduler.getScheduler().checkExists(jobKey)) 345 { 346 _scheduler.getScheduler().deleteJob(jobKey); 347 } 348 } 349 } 350 351 /** 352 * Invalidates the front-office from the event observed. 353 * @param sitemap the sitemap. 354 * @throws Exception if an error occurs. 355 */ 356 public void invalidateFOCache(Sitemap sitemap) throws Exception 357 { 358 testWS("/_invalidate-page/" + sitemap.getSiteName() + "/" + sitemap.getName()); 359 } 360 361 /** 362 * Invalidates the front-office from the event observed. 363 * @param page the page. 364 * @param recursively true to invalidate the sub-pages 365 * @throws Exception if an error occurs. 366 */ 367 public void invalidateFOCache(Page page, boolean recursively) throws Exception 368 { 369 testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + ".html"); 370 371 if (recursively) 372 { 373 testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap()); 374 } 375 } 376 377 /** 378 * Invalidates the front-office from the event observed. 379 * @param page the page. 380 * @throws Exception if an error occurs. 381 */ 382 public void invalidateFOCache(Page page) throws Exception 383 { 384 invalidateFOCache(page, false); 385 } 386 387 /** 388 * Delay the cache invalidation 389 * @param siteName the site name 390 */ 391 private void _delayFOCacheInvalidation(String siteName) 392 { 393 synchronized (_lockToken) 394 { 395 try 396 { 397 // Add job if not exists 398 InvalidateRemoteFOCacheRunnable invalidateFOCacheRunnable = new InvalidateRemoteFOCacheRunnable(siteName); 399 JobKey jobKey = new JobKey(invalidateFOCacheRunnable.getId(), Scheduler.JOB_GROUP); 400 401 // Schedule the job if it is not already scheduled 402 if (!_scheduler.getScheduler().checkExists(jobKey)) 403 { 404 // Execute immediately if there is no registered next invalidate date 405 ZonedDateTime nextInvalidationDate = _nextInvalidateDate.getOrDefault(siteName, ZonedDateTime.now()); 406 407 // Set the invalidation date to the next valid invalidation date 408 // If the date is past, the process will be executed immediately 409 invalidateFOCacheRunnable.setInvalidationDate(nextInvalidationDate); 410 _scheduler.scheduleJob(invalidateFOCacheRunnable); 411 } 412 } 413 catch (SchedulerException e) 414 { 415 getLogger().error("An error occurred when trying to schedule the cache invalidation for site {}", siteName, e); 416 } 417 } 418 } 419}