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.nio.charset.StandardCharsets;
023import java.util.ArrayList;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Timer;
028import java.util.TimerTask;
029
030import javax.xml.parsers.DocumentBuilder;
031import javax.xml.parsers.DocumentBuilderFactory;
032import javax.xml.parsers.ParserConfigurationException;
033import javax.xml.transform.TransformerException;
034
035import org.apache.commons.lang3.StringUtils;
036import org.apache.http.Header;
037import org.apache.http.HttpResponse;
038import org.apache.http.NameValuePair;
039import org.apache.http.client.ClientProtocolException;
040import org.apache.http.client.config.RequestConfig;
041import org.apache.http.client.entity.UrlEncodedFormEntity;
042import org.apache.http.client.methods.CloseableHttpResponse;
043import org.apache.http.client.methods.HttpPost;
044import org.apache.http.impl.client.CloseableHttpClient;
045import org.apache.http.impl.client.HttpClientBuilder;
046import org.apache.tika.io.IOUtils;
047import org.apache.xpath.XPathAPI;
048import org.slf4j.Logger;
049import org.w3c.dom.Document;
050import org.xml.sax.SAXException;
051
052import org.ametys.runtime.config.Config;
053import org.ametys.web.repository.page.Page;
054import org.ametys.web.repository.site.Site;
055import org.ametys.web.repository.sitemap.Sitemap;
056
057/**
058 * Helper for dealing with front-office cache.
059 */
060public final class CacheHelper
061{
062    static Integer _lockToken = 0;
063    static Map<String, Long> _lastInvalidationDate = new HashMap<>();
064    static Map<String, String> _timerTasks = new HashMap<>();
065    
066    static Timer _timer = new Timer("timer-cache", true);
067    
068    private static Map<String, Long> _cacheForPeriodOfValidity = new HashMap<>();
069    
070    private CacheHelper()
071    {
072        // empty constructor
073    }
074
075    /**
076     * Request the given URL on each configured front-office.
077     * @param url the url to be called.
078     * @param logger logger for traces.
079     * @throws Exception if an error occurred.
080     */
081    public static void testWS(String url, Logger logger) throws Exception
082    {
083        testWS(url, null, logger);
084    }
085
086    /**
087     * Request the given URL on each configured front-office.
088     * @param url the url to be called.
089     * @param postParameters submited values
090     * @param logger logger for traces.
091     * @throws Exception if an error occurred.
092     */
093    public static void testWS(String url, List<NameValuePair> postParameters, Logger logger) throws Exception
094    {
095        List<Map<String, Object>> responses = callWS(url, postParameters, logger);
096        for (Map<String, Object> response : responses)
097        {
098            byte[] byteArray = (byte[]) response.get("bodyResponse");
099            HttpPost request = (HttpPost) response.get("request");
100            HttpResponse httpresponse = (HttpResponse) response.get("response");
101            
102            if (_checkResponse(byteArray))
103            {
104                if (logger.isDebugEnabled())
105                {
106                    logger.debug("Request for cache invalidation sent and received successfully to: " + request.getURI().getHost());
107                }
108            }
109            else
110            {
111                logger.error("Unable to invalidate cache with request '" + url + "' to server '" + request.getURI().getHost() + "', response: " + (httpresponse != null ? httpresponse.getStatusLine().toString() : "<no response>"));
112            }
113        }
114    }
115    
116    /**
117     * Request the given URL on each configured front-office.
118     * @param url the url to be called.
119     * @param logger logger for traces.
120     * @return The streams
121     * @throws Exception if an error occurred.
122     */
123    public static List<Map<String, Object>> callWS(String url, Logger logger) throws Exception
124    {
125        return callWS(url, null, logger);
126    }
127    
128    /**
129     * Request the given URL on each configured front-office.
130     * @param url the url to be called.
131     * @param postParameters submitted values
132     * @param logger logger for traces.
133     * @return The streams
134     * @throws Exception if an error occurred.
135     */
136    public static List<Map<String, Object>> callWS(String url, List<NameValuePair> postParameters, Logger logger) throws Exception
137    {
138        String frontConfig = Config.getInstance().getValueAsString("org.ametys.web.front.url");
139        String[] frontURLs = StringUtils.split(frontConfig, ",");
140        
141        List<Map<String, Object>> responses = new ArrayList<>();
142        for (String rawFrontURL : frontURLs)
143        {
144            String frontURL = rawFrontURL.trim();
145            
146            RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(2000).setSocketTimeout(2000).build();
147            
148            String wsURL = frontURL + url;
149            
150            try (CloseableHttpClient httpclient = HttpClientBuilder.create().useSystemProperties().setDefaultRequestConfig(requestConfig).build())
151            {
152                
153                // Prepare a request object
154                HttpPost request = new HttpPost(wsURL);
155                request.addHeader("X-Ametys-BO", "true");
156                
157                if (postParameters != null)
158                {
159                    request.setEntity(new UrlEncodedFormEntity(postParameters, StandardCharsets.UTF_8));
160                }
161                
162                Map<String, Object> responseObject = new HashMap<>();
163                
164                // Execute the request
165                try (CloseableHttpResponse response = httpclient.execute(request))
166                {
167                    responseObject.put("bodyResponse", _getResponse(response));
168                    responseObject.put("response", response);
169                }
170                
171                responses.add(responseObject);
172                responseObject.put("request", request);
173            }
174            catch (Exception e)
175            {
176                if (e instanceof IOException || e instanceof ClientProtocolException)
177                {
178                    logger.error("Unable to send request: " + wsURL, e);
179                }
180                else
181                {
182                    throw e;
183                }
184            }
185        }
186        
187        return responses;
188    }
189
190    private static boolean _checkResponse(byte[] bodyResponse) throws ParserConfigurationException, IllegalStateException, IOException, SAXException, TransformerException
191    {
192        if (bodyResponse == null)
193        {
194            return false;
195        }
196        
197        try (InputStream is = new ByteArrayInputStream(bodyResponse))
198        {
199            DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
200            Document document = docBuilder.parse(is);
201            return XPathAPI.eval(document, "count(/ActionResult)").toString().equals("1");
202        }
203    }
204    
205    private static byte[] _getResponse(HttpResponse response) throws IllegalStateException, IOException
206    {
207        if (response.getStatusLine().getStatusCode() != 200)
208        {
209            return null;
210        }
211        
212        if (response.getFirstHeader("X-Ametys-SafeMode") != null)
213        {
214            // Site application is in safe mode
215            return null;
216        }
217        
218        String cType = _getContentType (response);
219        if (cType == null || !cType.startsWith("text/xml"))
220        {
221            return null;
222        }
223        
224        try (InputStream is = response.getEntity().getContent())
225        {
226            return IOUtils.toByteArray(is);
227        }
228    }
229    
230    private static String _getContentType (HttpResponse invalidateResponse)
231    {
232        Header header = invalidateResponse.getFirstHeader("Content-Type");
233        if (header != null)
234        {
235            return header.getValue();
236        }
237        return null;
238    }
239
240    /**
241     * Invalidates the front-office from the event observed.
242     * @param site the site.
243     * @param logger logger for traces.
244     * @throws Exception if an error occurs.
245     */
246    public static void invalidateCache(Site site, Logger logger) throws Exception
247    {
248        String siteName = site.getName();
249        long periodOfValidity = _getPeriodOfValidity (site, siteName);
250        
251        if (isCacheValidityExpired(siteName, periodOfValidity))
252        {
253            testWS("/_invalidate-site/" + site.getName(), logger);
254            testWS("/_invalidate-skin/" + site.getSkinId(), logger);
255        }
256        else
257        {
258            delayCacheInvalidation(siteName, "/_invalidate-site/" + site.getName(), periodOfValidity, logger);
259            delayCacheInvalidation(siteName, "/_invalidate-skin/" + site.getName(), periodOfValidity, logger);
260        }
261    }
262    
263    /**
264     * Invalidates the front-office from the event observed.
265     * @param sitemap the sitemap.
266     * @param logger logger for traces.
267     * @throws Exception if an error occurs.
268     */
269    public static void invalidateCache(Sitemap sitemap, Logger logger) throws Exception
270    {
271        testWS("/_invalidate-page/" + sitemap.getSiteName() + "/" + sitemap.getName(), logger);
272    }
273    
274    /**
275     * Invalidates the front-office from the event observed.
276     * @param page the page.
277     * @param logger logger for traces.
278     * @param recursively true to invalidate the sub-pages
279     * @throws Exception if an error occurs.
280     */
281    public static void invalidateCache(Page page, Logger logger, boolean recursively) throws Exception
282    {
283        testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + ".html", logger);
284        
285        if (recursively)
286        {
287            testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap(), logger);
288        }
289    }
290    
291    /**
292     * Invalidates the front-office from the event observed.
293     * @param page the page.
294     * @param logger logger for traces.
295     * @throws Exception if an error occurs.
296     */
297    public static void invalidateCache(Page page, Logger logger) throws Exception
298    {
299        invalidateCache(page, logger, false);
300    }
301    
302    private static long _getPeriodOfValidity (Site site, String siteName)
303    {
304        if (!_cacheForPeriodOfValidity.containsKey(siteName))
305        {
306            _cacheForPeriodOfValidity.put(siteName, site.getMetadataHolder().getLong("cache-validity", 0) * 1000);
307        }
308        return _cacheForPeriodOfValidity.get(siteName);
309    }
310    
311    /**
312     * Update the last invalidation cache
313     * @param siteName the site name
314     */
315    public static void initializeTimerPeriod (String siteName)
316    {
317        synchronized (_lockToken)
318        {
319            _lastInvalidationDate.put(siteName, System.currentTimeMillis());
320            _timerTasks.remove(siteName);
321        }
322    }
323    
324    /**
325     * Determines if cache validity is expired for given site
326     * @param siteName the site name
327     * @param periodOfValidity the period of cache validity
328     * @return true if cache validity is expired
329     */
330    public static boolean isCacheValidityExpired (String siteName, long periodOfValidity)
331    {
332        synchronized (_lockToken)
333        {
334            long currentMillisecond = System.currentTimeMillis();
335            
336            if (!_lastInvalidationDate.containsKey(siteName))
337            {   
338                _lastInvalidationDate.put(siteName, currentMillisecond);
339                return true;
340            }
341            
342            long lastInvalidationDate = _lastInvalidationDate.get(siteName);
343            
344            if (currentMillisecond - lastInvalidationDate >= periodOfValidity)
345            {
346                _lastInvalidationDate.put(siteName, currentMillisecond);
347                return true;
348            }
349            
350            // Cache is still valid
351            return false;
352        }
353    }
354    
355    /**
356     * Delay the cache invalidation
357     * @param siteName the site name
358     * @param urlWS the WS url to be called
359     * @param periodOfValidity the period of cache validity
360     * @param logger the logger
361     */
362    public static void delayCacheInvalidation (String siteName, String urlWS, long periodOfValidity, Logger logger)
363    {
364        synchronized (_lockToken)
365        {
366            if (!_timerTasks.containsKey(siteName))
367            {
368                _timerTasks.put(siteName, urlWS);
369                
370                long currentMillisecond = System.currentTimeMillis();
371                
372                long lastInvalidationDate = _lastInvalidationDate.get(siteName);
373                
374                // delay the cache invalidation
375                long delay = lastInvalidationDate + periodOfValidity - currentMillisecond;
376                
377                if (logger.isDebugEnabled())
378                {
379                    logger.debug("Cache invalidation for site '" + siteName + "' has been delayed of " + delay + " milliseconds");
380                }
381                _timer.schedule(new InvalidateCacheTimerTask (siteName, logger), delay);
382            }
383        }
384    }
385    
386    /**
387     * {@link TimerTask} to invalidate site cache
388     *
389     */
390    public static class InvalidateCacheTimerTask extends TimerTask
391    {
392        private String _siteName;
393        private Logger _logger;
394        
395        /**
396         * Constructor
397         * @param siteName the site name
398         * @param logger the logger
399         */
400        public InvalidateCacheTimerTask (String siteName, Logger logger)
401        {
402            _siteName = siteName;
403            _logger = logger;
404        }
405        
406        @Override
407        public void run()
408        {
409            try
410            {
411                synchronized (_lockToken)
412                {
413                    if (_logger.isDebugEnabled())
414                    {
415                        _logger.debug("Invalide cache of site '" + _siteName + "' after delay.");
416                    }
417                    
418                    CacheHelper.testWS(_timerTasks.get(_siteName), _logger);
419                    CacheHelper.initializeTimerPeriod(_siteName);
420                }
421            }
422            catch (Exception e)
423            {
424                _logger.error("Unable to invalidate cache for site: " + _siteName, e);
425            }
426        }
427    }
428}