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}