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                responses.add(responseObject);
164                responseObject.put("request", request);
165                
166                // Execute the request
167                try (CloseableHttpResponse response = httpclient.execute(request))
168                {
169                    responseObject.put("bodyResponse", _getResponse(response));
170                    responseObject.put("response", response);
171                }
172            }
173            catch (Exception e)
174            {
175                if (e instanceof IOException || e instanceof ClientProtocolException)
176                {
177                    logger.error("Unable to send request: " + wsURL, e);
178                }
179                else
180                {
181                    throw e;
182                }
183            }
184        }
185        
186        return responses;
187    }
188
189    private static boolean _checkResponse(byte[] bodyResponse) throws ParserConfigurationException, IllegalStateException, IOException, SAXException, TransformerException
190    {
191        if (bodyResponse == null)
192        {
193            return false;
194        }
195        
196        try (InputStream is = new ByteArrayInputStream(bodyResponse))
197        {
198            DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
199            Document document = docBuilder.parse(is);
200            return XPathAPI.eval(document, "count(/ActionResult)").toString().equals("1");
201        }
202    }
203    
204    private static byte[] _getResponse(HttpResponse response) throws IllegalStateException, IOException
205    {
206        if (response.getStatusLine().getStatusCode() != 200)
207        {
208            return null;
209        }
210        
211        if (response.getFirstHeader("X-Ametys-SafeMode") != null)
212        {
213            // Site application is in safe mode
214            return null;
215        }
216        
217        String cType = _getContentType (response);
218        if (cType == null || !cType.startsWith("text/xml"))
219        {
220            return null;
221        }
222        
223        try (InputStream is = response.getEntity().getContent())
224        {
225            return IOUtils.toByteArray(is);
226        }
227    }
228    
229    private static String _getContentType (HttpResponse invalidateResponse)
230    {
231        Header header = invalidateResponse.getFirstHeader("Content-Type");
232        if (header != null)
233        {
234            return header.getValue();
235        }
236        return null;
237    }
238
239    /**
240     * Invalidates the front-office from the event observed.
241     * @param site the site.
242     * @param logger logger for traces.
243     * @throws Exception if an error occurs.
244     */
245    public static void invalidateCache(Site site, Logger logger) throws Exception
246    {
247        String siteName = site.getName();
248        long periodOfValidity = _getPeriodOfValidity (site, siteName);
249        
250        if (isCacheValidityExpired(siteName, periodOfValidity))
251        {
252            testWS("/_invalidate-site/" + site.getName(), logger);
253            testWS("/_invalidate-skin/" + site.getSkinId(), logger);
254        }
255        else
256        {
257            delayCacheInvalidation(siteName, "/_invalidate-site/" + site.getName(), periodOfValidity, logger);
258            delayCacheInvalidation(siteName, "/_invalidate-skin/" + site.getName(), periodOfValidity, logger);
259        }
260    }
261    
262    /**
263     * Invalidates the front-office from the event observed.
264     * @param sitemap the sitemap.
265     * @param logger logger for traces.
266     * @throws Exception if an error occurs.
267     */
268    public static void invalidateCache(Sitemap sitemap, Logger logger) throws Exception
269    {
270        testWS("/_invalidate-page/" + sitemap.getSiteName() + "/" + sitemap.getName(), logger);
271    }
272    
273    /**
274     * Invalidates the front-office from the event observed.
275     * @param page the page.
276     * @param logger logger for traces.
277     * @param recursively true to invalidate the sub-pages
278     * @throws Exception if an error occurs.
279     */
280    public static void invalidateCache(Page page, Logger logger, boolean recursively) throws Exception
281    {
282        testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + ".html", logger);
283        
284        if (recursively)
285        {
286            testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap(), logger);
287        }
288    }
289    
290    /**
291     * Invalidates the front-office from the event observed.
292     * @param page the page.
293     * @param logger logger for traces.
294     * @throws Exception if an error occurs.
295     */
296    public static void invalidateCache(Page page, Logger logger) throws Exception
297    {
298        invalidateCache(page, logger, false);
299    }
300    
301    private static long _getPeriodOfValidity (Site site, String siteName)
302    {
303        if (!_cacheForPeriodOfValidity.containsKey(siteName))
304        {
305            _cacheForPeriodOfValidity.put(siteName, site.getMetadataHolder().getLong("cache-validity", 0) * 1000);
306        }
307        return _cacheForPeriodOfValidity.get(siteName);
308    }
309    
310    /**
311     * Update the last invalidation cache
312     * @param siteName the site name
313     */
314    public static void initializeTimerPeriod (String siteName)
315    {
316        synchronized (_lockToken)
317        {
318            _lastInvalidationDate.put(siteName, System.currentTimeMillis());
319            _timerTasks.remove(siteName);
320        }
321    }
322    
323    /**
324     * Determines if cache validity is expired for given site
325     * @param siteName the site name
326     * @param periodOfValidity the period of cache validity
327     * @return true if cache validity is expired
328     */
329    public static boolean isCacheValidityExpired (String siteName, long periodOfValidity)
330    {
331        synchronized (_lockToken)
332        {
333            long currentMillisecond = System.currentTimeMillis();
334            
335            if (!_lastInvalidationDate.containsKey(siteName))
336            {   
337                _lastInvalidationDate.put(siteName, currentMillisecond);
338                return true;
339            }
340            
341            long lastInvalidationDate = _lastInvalidationDate.get(siteName);
342            
343            if (currentMillisecond - lastInvalidationDate >= periodOfValidity)
344            {
345                _lastInvalidationDate.put(siteName, currentMillisecond);
346                return true;
347            }
348            
349            // Cache is still valid
350            return false;
351        }
352    }
353    
354    /**
355     * Delay the cache invalidation
356     * @param siteName the site name
357     * @param urlWS the WS url to be called
358     * @param periodOfValidity the period of cache validity
359     * @param logger the logger
360     */
361    public static void delayCacheInvalidation (String siteName, String urlWS, long periodOfValidity, Logger logger)
362    {
363        synchronized (_lockToken)
364        {
365            if (!_timerTasks.containsKey(siteName))
366            {
367                _timerTasks.put(siteName, urlWS);
368                
369                long currentMillisecond = System.currentTimeMillis();
370                
371                long lastInvalidationDate = _lastInvalidationDate.get(siteName);
372                
373                // delay the cache invalidation
374                long delay = lastInvalidationDate + periodOfValidity - currentMillisecond;
375                
376                if (logger.isDebugEnabled())
377                {
378                    logger.debug("Cache invalidation for site '" + siteName + "' has been delayed of " + delay + " milliseconds");
379                }
380                _timer.schedule(new InvalidateCacheTimerTask (siteName, logger), delay);
381            }
382        }
383    }
384    
385    /**
386     * {@link TimerTask} to invalidate site cache
387     *
388     */
389    public static class InvalidateCacheTimerTask extends TimerTask
390    {
391        private String _siteName;
392        private Logger _logger;
393        
394        /**
395         * Constructor
396         * @param siteName the site name
397         * @param logger the logger
398         */
399        public InvalidateCacheTimerTask (String siteName, Logger logger)
400        {
401            _siteName = siteName;
402            _logger = logger;
403        }
404        
405        @Override
406        public void run()
407        {
408            try
409            {
410                synchronized (_lockToken)
411                {
412                    if (_logger.isDebugEnabled())
413                    {
414                        _logger.debug("Invalide cache of site '" + _siteName + "' after delay.");
415                    }
416                    
417                    CacheHelper.testWS(_timerTasks.get(_siteName), _logger);
418                    CacheHelper.initializeTimerPeriod(_siteName);
419                }
420            }
421            catch (Exception e)
422            {
423                _logger.error("Unable to invalidate cache for site: " + _siteName, e);
424            }
425        }
426    }
427}