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.io.IOUtils;
036import org.apache.commons.lang3.StringUtils;
037import org.apache.http.Header;
038import org.apache.http.HttpResponse;
039import org.apache.http.NameValuePair;
040import org.apache.http.client.ClientProtocolException;
041import org.apache.http.client.config.RequestConfig;
042import org.apache.http.client.entity.UrlEncodedFormEntity;
043import org.apache.http.client.methods.CloseableHttpResponse;
044import org.apache.http.client.methods.HttpPost;
045import org.apache.http.impl.client.CloseableHttpClient;
046import org.apache.http.impl.client.HttpClientBuilder;
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     * Get front office applications urls
130     * @return The non null list of url configured. Can be empty.
131     */
132    public static String[] getFrontURLS()
133    {
134        String frontConfig = Config.getInstance().getValue("org.ametys.web.front.url");
135        String[] frontURLs = StringUtils.split(frontConfig, ",");
136        return frontURLs;
137    }
138    
139    /**
140     * Request the given URL on each configured front-office.
141     * @param url the url to be called.
142     * @param postParameters submitted values
143     * @param logger logger for traces.
144     * @return The streams
145     * @throws Exception if an error occurred.
146     */
147    public static List<Map<String, Object>> callWS(String url, List<NameValuePair> postParameters, Logger logger) throws Exception
148    {
149        String[] frontURLs = getFrontURLS();
150        
151        List<Map<String, Object>> responses = new ArrayList<>();
152        for (String rawFrontURL : frontURLs)
153        {
154            String frontURL = rawFrontURL.trim();
155            
156            RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(2000).setSocketTimeout(2000).build();
157            
158            String wsURL = frontURL + url;
159            
160            try (CloseableHttpClient httpclient = HttpClientBuilder.create().useSystemProperties().setDefaultRequestConfig(requestConfig).build())
161            {
162                
163                // Prepare a request object
164                HttpPost request = new HttpPost(wsURL);
165                request.addHeader("X-Ametys-BO", "true");
166                
167                if (postParameters != null)
168                {
169                    request.setEntity(new UrlEncodedFormEntity(postParameters, StandardCharsets.UTF_8));
170                }
171                
172                Map<String, Object> responseObject = new HashMap<>();
173                
174                // Execute the request
175                try (CloseableHttpResponse response = httpclient.execute(request))
176                {
177                    responseObject.put("bodyResponse", _getResponse(response));
178                    responseObject.put("response", response);
179                }
180                
181                responses.add(responseObject);
182                responseObject.put("request", request);
183            }
184            catch (Exception e)
185            {
186                if (e instanceof IOException || e instanceof ClientProtocolException)
187                {
188                    logger.error("Unable to send request: " + wsURL, e);
189                }
190                else
191                {
192                    throw e;
193                }
194            }
195        }
196        
197        return responses;
198    }
199
200    private static boolean _checkResponse(byte[] bodyResponse) throws ParserConfigurationException, IllegalStateException, IOException, SAXException, TransformerException
201    {
202        if (bodyResponse == null)
203        {
204            return false;
205        }
206        
207        try (InputStream is = new ByteArrayInputStream(bodyResponse))
208        {
209            DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
210            Document document = docBuilder.parse(is);
211            return XPathAPI.eval(document, "count(/ActionResult)").toString().equals("1");
212        }
213    }
214    
215    private static byte[] _getResponse(HttpResponse response) throws IllegalStateException, IOException
216    {
217        if (response.getStatusLine().getStatusCode() != 200)
218        {
219            return null;
220        }
221        
222        if (response.getFirstHeader("X-Ametys-SafeMode") != null)
223        {
224            // Site application is in safe mode
225            return null;
226        }
227        
228        String cType = _getContentType (response);
229        if (cType == null || !cType.startsWith("text/xml"))
230        {
231            return null;
232        }
233        
234        try (InputStream is = response.getEntity().getContent())
235        {
236            return IOUtils.toByteArray(is);
237        }
238    }
239    
240    private static String _getContentType (HttpResponse invalidateResponse)
241    {
242        Header header = invalidateResponse.getFirstHeader("Content-Type");
243        if (header != null)
244        {
245            return header.getValue();
246        }
247        return null;
248    }
249
250    /**
251     * Invalidates the front-office from the event observed.
252     * @param site the site.
253     * @param logger logger for traces.
254     * @throws Exception if an error occurs.
255     */
256    public static void invalidateCache(Site site, Logger logger) throws Exception
257    {
258        String siteName = site.getName();
259        long periodOfValidity = _getPeriodOfValidity (site, siteName);
260        
261        if (isCacheValidityExpired(siteName, periodOfValidity))
262        {
263            testWS("/_invalidate-site/" + site.getName(), logger);
264            testWS("/_invalidate-skin/" + site.getSkinId(), logger);
265        }
266        else
267        {
268            delayCacheInvalidation(siteName, "/_invalidate-site/" + site.getName(), periodOfValidity, logger);
269            delayCacheInvalidation(siteName, "/_invalidate-skin/" + site.getName(), periodOfValidity, logger);
270        }
271    }
272    
273    /**
274     * Invalidates the front-office from the event observed.
275     * @param sitemap the sitemap.
276     * @param logger logger for traces.
277     * @throws Exception if an error occurs.
278     */
279    public static void invalidateCache(Sitemap sitemap, Logger logger) throws Exception
280    {
281        testWS("/_invalidate-page/" + sitemap.getSiteName() + "/" + sitemap.getName(), logger);
282    }
283    
284    /**
285     * Invalidates the front-office from the event observed.
286     * @param page the page.
287     * @param logger logger for traces.
288     * @param recursively true to invalidate the sub-pages
289     * @throws Exception if an error occurs.
290     */
291    public static void invalidateCache(Page page, Logger logger, boolean recursively) throws Exception
292    {
293        testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + ".html", logger);
294        
295        if (recursively)
296        {
297            testWS("/_invalidate-page/" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap(), logger);
298        }
299    }
300    
301    /**
302     * Invalidates the front-office from the event observed.
303     * @param page the page.
304     * @param logger logger for traces.
305     * @throws Exception if an error occurs.
306     */
307    public static void invalidateCache(Page page, Logger logger) throws Exception
308    {
309        invalidateCache(page, logger, false);
310    }
311    
312    private static long _getPeriodOfValidity (Site site, String siteName)
313    {
314        if (!_cacheForPeriodOfValidity.containsKey(siteName))
315        {
316            _cacheForPeriodOfValidity.put(siteName, site.getValue("cache-validity", false, 0L) * 1000);
317        }
318        return _cacheForPeriodOfValidity.get(siteName);
319    }
320    
321    /**
322     * Update the last invalidation cache
323     * @param siteName the site name
324     */
325    public static void initializeTimerPeriod (String siteName)
326    {
327        synchronized (_lockToken)
328        {
329            _lastInvalidationDate.put(siteName, System.currentTimeMillis());
330            _timerTasks.remove(siteName);
331        }
332    }
333    
334    /**
335     * Determines if cache validity is expired for given site
336     * @param siteName the site name
337     * @param periodOfValidity the period of cache validity
338     * @return true if cache validity is expired
339     */
340    public static boolean isCacheValidityExpired (String siteName, long periodOfValidity)
341    {
342        synchronized (_lockToken)
343        {
344            long currentMillisecond = System.currentTimeMillis();
345            
346            if (!_lastInvalidationDate.containsKey(siteName))
347            {   
348                _lastInvalidationDate.put(siteName, currentMillisecond);
349                return true;
350            }
351            
352            long lastInvalidationDate = _lastInvalidationDate.get(siteName);
353            
354            if (currentMillisecond - lastInvalidationDate >= periodOfValidity)
355            {
356                _lastInvalidationDate.put(siteName, currentMillisecond);
357                return true;
358            }
359            
360            // Cache is still valid
361            return false;
362        }
363    }
364    
365    /**
366     * Delay the cache invalidation
367     * @param siteName the site name
368     * @param urlWS the WS url to be called
369     * @param periodOfValidity the period of cache validity
370     * @param logger the logger
371     */
372    public static void delayCacheInvalidation (String siteName, String urlWS, long periodOfValidity, Logger logger)
373    {
374        synchronized (_lockToken)
375        {
376            if (!_timerTasks.containsKey(siteName))
377            {
378                _timerTasks.put(siteName, urlWS);
379                
380                long currentMillisecond = System.currentTimeMillis();
381                
382                long lastInvalidationDate = _lastInvalidationDate.get(siteName);
383                
384                // delay the cache invalidation
385                long delay = lastInvalidationDate + periodOfValidity - currentMillisecond;
386                
387                if (logger.isDebugEnabled())
388                {
389                    logger.debug("Cache invalidation for site '" + siteName + "' has been delayed of " + delay + " milliseconds");
390                }
391                _timer.schedule(new InvalidateCacheTimerTask (siteName, logger), delay);
392            }
393        }
394    }
395    
396    /**
397     * {@link TimerTask} to invalidate site cache
398     *
399     */
400    public static class InvalidateCacheTimerTask extends TimerTask
401    {
402        private String _siteName;
403        private Logger _logger;
404        
405        /**
406         * Constructor
407         * @param siteName the site name
408         * @param logger the logger
409         */
410        public InvalidateCacheTimerTask (String siteName, Logger logger)
411        {
412            _siteName = siteName;
413            _logger = logger;
414        }
415        
416        @Override
417        public void run()
418        {
419            try
420            {
421                synchronized (_lockToken)
422                {
423                    if (_logger.isDebugEnabled())
424                    {
425                        _logger.debug("Invalide cache of site '" + _siteName + "' after delay.");
426                    }
427                    
428                    CacheHelper.testWS(_timerTasks.get(_siteName), _logger);
429                    CacheHelper.initializeTimerPeriod(_siteName);
430                }
431            }
432            catch (Exception e)
433            {
434                _logger.error("Unable to invalidate cache for site: " + _siteName, e);
435            }
436        }
437    }
438}