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 */
016package org.ametys.site;
017
018import java.io.File;
019import java.io.FileOutputStream;
020import java.io.IOException;
021import java.net.HttpCookie;
022import java.util.Enumeration;
023import java.util.List;
024import java.util.Map;
025
026import org.apache.avalon.framework.parameters.Parameters;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.thread.ThreadSafe;
030import org.apache.cocoon.ProcessingException;
031import org.apache.cocoon.ResourceNotFoundException;
032import org.apache.cocoon.acting.ServiceableAction;
033import org.apache.cocoon.environment.Cookie;
034import org.apache.cocoon.environment.ObjectModelHelper;
035import org.apache.cocoon.environment.PermanentRedirector;
036import org.apache.cocoon.environment.Redirector;
037import org.apache.cocoon.environment.Request;
038import org.apache.cocoon.environment.Response;
039import org.apache.commons.lang.StringUtils;
040import org.apache.http.Header;
041import org.apache.http.HttpResponse;
042import org.apache.http.client.methods.HttpUriRequest;
043import org.apache.http.impl.client.CloseableHttpClient;
044
045import org.ametys.core.user.UserIdentity;
046import org.ametys.core.util.URIUtils;
047import org.ametys.plugins.site.Site;
048import org.ametys.plugins.site.SiteUrl;
049import org.ametys.plugins.site.proxy.BackOfficeRequestProxy;
050import org.ametys.plugins.site.proxy.BackOfficeRequestProxyExtensionPoint;
051import org.ametys.runtime.authentication.AccessDeniedException;
052import org.ametys.runtime.exception.ServiceUnavailableException;
053
054/**
055 * Call the BO for getting a page content and stores it in the local cache.
056 */
057public class GeneratePageAction extends ServiceableAction implements ThreadSafe
058{
059    static final String __BACKOFFICE_JSESSION_ID = "JSESSIONID-Ametys";
060    
061    private CacheAccessManager _cacheAccess;
062    private CacheAccessCounter _cacheAccessCounter;
063    private BackOfficeRequestProxyExtensionPoint _requestHeaderEP;
064    
065    @Override
066    public void service(ServiceManager sManager) throws ServiceException
067    {
068        super.service(sManager);
069        _requestHeaderEP = (BackOfficeRequestProxyExtensionPoint) sManager.lookup(BackOfficeRequestProxyExtensionPoint.ROLE);
070    }
071
072    /**
073     * Get the cache access counter
074     * @return the CacheAccessCounter
075     */
076    protected CacheAccessCounter _getCacheAccessCounter()
077    {
078        if (_cacheAccessCounter == null)
079        {
080            try
081            {
082                _cacheAccessCounter = (CacheAccessCounter) manager.lookup(CacheAccessCounter.ROLE);
083            }
084            catch (ServiceException e)
085            {
086                throw new IllegalStateException("Cannot get CacheAccessCounter", e);
087            }
088        }
089        return _cacheAccessCounter;
090    }
091    
092    /**
093     * Get the cache access manager
094     * @return the CacheAccessManager
095     */
096    protected CacheAccessManager _getCacheAccessManager()
097    {
098        if (_cacheAccess == null)
099        {
100            try
101            {
102                _cacheAccess = (CacheAccessManager) manager.lookup(CacheAccessManager.ROLE);
103            }
104            catch (ServiceException e)
105            {
106                throw new IllegalStateException("Cannot get CacheAccessManager", e);
107            }
108        }
109        return _cacheAccess;
110    }
111    
112    @Override
113    public Map act(Redirector redirector, org.apache.cocoon.environment.SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
114    {
115        Request request = ObjectModelHelper.getRequest(objectModel);
116        Response response = ObjectModelHelper.getResponse(objectModel);
117
118        String page = parameters.getParameter("page");
119        _log("Generating the resource " + page);
120        
121        Site site = (Site) request.getAttribute("site");
122        if (site != null)
123        {
124            _getCacheAccessCounter().increaseAskedResources(site.getName());
125        }
126        
127        CloseableHttpClient httpClient = null;
128        try
129        {
130            // If the request was previously done (to test cachability), get it back
131            httpClient = (CloseableHttpClient) request.getAttribute("http-client");
132            HttpUriRequest cmsRequest = (HttpUriRequest) request.getAttribute("cms-request");
133            HttpResponse cmsResponse = (HttpResponse) request.getAttribute("cms-response");
134            
135            // If page was already known as non cacheable, we should do the request by now
136            if (cmsResponse == null)
137            {
138                httpClient = BackOfficeRequestHelper.getHttpClient();
139                cmsRequest = BackOfficeRequestHelper.getRequest(objectModel, page, _requestHeaderEP);
140                cmsResponse = httpClient.execute(cmsRequest);
141                
142                request.setAttribute("cms-request", cmsRequest);
143                request.setAttribute("cms-response", cmsResponse);
144                request.setAttribute("http-client", httpClient);
145            }
146            
147            // Copy the Set-Cookie headers in the response.
148            _copyHeaders(request, response, cmsResponse, new String[]{"Content-Disposition", "Content-Length", "Cache-Control", "Allow", "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "ETag", "Last-Modified"});
149            
150            for (String requestId : _requestHeaderEP.getExtensionsIds())
151            {
152                BackOfficeRequestProxy boRequestComponent = _requestHeaderEP.getExtension(requestId);
153                boRequestComponent.handleBackOfficeResponse(response, cmsResponse);
154            }
155            
156            // Handle the request
157            int statusCode = cmsResponse.getStatusLine().getStatusCode();
158            switch (statusCode)
159            {
160                case 200:
161                    Header header = cmsResponse.getFirstHeader("X-Ametys-Cacheable");
162                    Object editionMode = request.getAttribute(GetSiteAction.EDITION_URI);
163                    if (header != null && "true".equals(header.getValue()) && !"true".equals(editionMode))
164                    {
165                        _writePageOnDisk(page, cmsResponse);
166                        
167                        _log("Succeed to generate cacheable resource '" + page + "'");
168                        
169                        return null;
170                    }
171                    
172                    // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
173                    httpClient = null;
174                    _log("Succeed to generate uncacheable resource '" + page + "'");
175                    
176                    return EMPTY_MAP;
177                case 204:
178                case 207:
179                case 304:
180                    ((org.apache.cocoon.environment.http.HttpResponse) response).setStatus(statusCode);
181                    
182                    // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
183                    httpClient = null;
184                    return EMPTY_MAP;
185                    
186                case 301: // Permanent redirection
187                    _redirect(cmsResponse, redirector, page, true);
188                    return null;
189                    
190                case 302: // Redirection
191                    _redirect(cmsResponse, redirector, page, false);
192                    return null;
193                    
194                case 401: // authorization required
195                    if (site == null)
196                    {
197                        throw new IllegalStateException("Cannot authenticate outsite a site");
198                    }
199                    
200                    SiteUrl siteUrl = site.getSiteUrls().get(0);
201                    
202                    redirector.redirect(false, siteUrl.getBaseServerPath(request) + siteUrl.getServerPath() + "/_authenticate?requestedURL=" + _encodeRequestedUrl(request));
203                    _log("Resource '" + page + "' needs authentication");
204                    return null;
205                    
206                case 403: // access denied
207                    UserIdentity user = site != null ? FrontAuthenticateAction.getUserIdentityFromSession(request, site.getName()) : null;
208                    String userStr = user != null ? "user " + user.toString() : " anonymous user";
209                    
210                    _log("Access denied for resource '" + page + "' for " + userStr);
211                    throw new AccessDeniedException("Access denied for " + userStr + " for URL " + cmsRequest.getURI());
212                    
213                case 404: // not found
214                    _log("Resource not found '" + page + "'");
215                    throw new ResourceNotFoundException("Resource not found for URL " + cmsRequest.getURI());
216                    
217                case 503: // Incomplete configuration or site down.
218                    _log("Site down for URL '" + page + "'");
219                    throw new ServiceUnavailableException("Site down for URL " + cmsRequest.getURI());
220                    
221                default:
222                    _log("Unable to get resource '" + page + "'. Status code is " + statusCode);
223                    throw new ProcessingException("Unable to get URL '" + page + "' at URL '" + cmsRequest.getURI() + "'. Status code is " + statusCode);
224            }
225        }
226        finally
227        {
228            // Whatever happens, unlock the page.
229            _getCacheAccessManager().unlock(page);
230            
231            if (httpClient != null)
232            {
233                httpClient.close();
234            }
235        }
236    }
237    
238    private String _encodeRequestedUrl(Request request)
239    {
240        String requestedURI = (String) request.getAttribute("requestedURI");
241        
242        // Transmit parameters
243        StringBuilder transmittedParameters = new StringBuilder();
244        boolean first = true;
245        Enumeration<String> parameterNames = request.getParameterNames();
246        
247        while (parameterNames.hasMoreElements())
248        {
249            if (first)
250            {
251                transmittedParameters.append("?");
252                first = false;
253            }
254            else
255            {
256                transmittedParameters.append("&");
257            }
258            String parameterName = parameterNames.nextElement();
259            transmittedParameters.append(parameterName);
260            transmittedParameters.append("=");
261            transmittedParameters.append(URIUtils.encodeParameter(request.getParameter(parameterName)));
262        }
263        
264        if (!first)
265        {
266            // request.getAttribute("requestedURI") is already encoded
267            requestedURI += URIUtils.encodeParameter(transmittedParameters.toString());
268        }
269        
270        return requestedURI;
271    }
272    
273    private void _log(String message)
274    {
275        if (getLogger().isDebugEnabled())
276        {
277            getLogger().debug(message);
278        }
279    }
280    
281    private void _redirect(HttpResponse cmsResponse, Redirector redirector, String page, boolean permanent) throws Exception
282    {
283        String location = cmsResponse.getFirstHeader("Location").getValue();
284        
285        if (permanent && redirector instanceof PermanentRedirector)
286        {
287            ((PermanentRedirector) redirector).permanentRedirect(false, location);
288        }
289        else
290        {
291            redirector.redirect(false, location);
292        }
293        
294        _log("Redirect '" + page + "' to '" + location + "'");
295    }
296    
297    /**
298     * Copy some response headers from the back-office.
299     * @param request the front-office client request.
300     * @param response the front-office client response.
301     * @param cmsResponse the response from the back-office.
302     * @param names the header names.
303     */
304    protected void _copyHeaders(Request request, Response response, HttpResponse cmsResponse, String[] names)
305    {
306        _transposeCookies(request, response, cmsResponse);
307        
308        for (String name : names)
309        {
310            Header[] headers = cmsResponse.getHeaders(name);
311            for (Header header : headers)
312            {
313                String value = header.getValue();
314                
315                response.addHeader(name, value);
316            }
317        }
318    }
319    
320    private void _transposeCookies(Request request, Response response, HttpResponse cmsResponse)
321    {
322        Header[] headers = cmsResponse.getHeaders("Set-Cookie");
323        for (Header header : headers)
324        {
325            String value = header.getValue();
326            
327            List<HttpCookie> cookies = HttpCookie.parse(value);
328            for (HttpCookie cookie : cookies)
329            {
330                String cookieName = cookie.getName();
331
332                if ("JSESSIONID".equals(cookieName))
333                {
334                    if (getLogger().isWarnEnabled())
335                    {
336                        getLogger().warn("Receiving JSESSIONID cookie from the back-office.");
337                    }
338                    cookieName = __BACKOFFICE_JSESSION_ID;
339                }
340                
341                Cookie newCookie = response.createCookie(cookieName, cookie.getValue());
342                javax.servlet.http.Cookie newHttpCookie = ((org.apache.cocoon.environment.http.HttpCookie) newCookie).getServletCookie();
343                newHttpCookie.setComment(cookie.getComment());
344                if (cookie.getDomain() != null)
345                {
346                    newHttpCookie.setDomain(cookie.getDomain());
347                }
348                newHttpCookie.setMaxAge((int) cookie.getMaxAge());
349                newHttpCookie.setPath(StringUtils.defaultIfEmpty(request.getContextPath(), "/"));
350                newHttpCookie.setSecure(request.isSecure());
351                newHttpCookie.setHttpOnly(cookie.isHttpOnly());
352                response.addCookie(newCookie);
353            }
354        }
355    }
356
357    /**
358     * Read the page from the back-office response and write it into the cache.
359     * @param decodedPage the page path.
360     * @param cmsResponse the back-office response, containing the page.
361     * @throws IOException if an error occurs writing the page into the cache.
362     */
363    protected void _writePageOnDisk(String decodedPage, HttpResponse cmsResponse) throws IOException
364    {
365        if (getLogger().isDebugEnabled())
366        {
367            getLogger().debug("The page is cacheable, writing into the cache: " + decodedPage);
368        }
369        
370        File root = SiteCacheHelper.getRootCache();
371        
372        File file = getFile(root, decodedPage);
373        if (!file.exists() && cmsResponse != null && cmsResponse.getEntity() != null)
374        {
375            file.getParentFile().mkdirs();
376            
377            try (FileOutputStream fos = new FileOutputStream(file);) 
378            {
379                cmsResponse.getEntity().writeTo(fos);
380            }
381            catch (IOException e)
382            {
383                throw new IOException("Error writing the file '" + file.getAbsolutePath() + "' into the cache, check that the directory is writable.", e);
384            }
385        }
386        
387        if (getLogger().isDebugEnabled())
388        {
389            getLogger().debug("Page " + decodedPage + " written into the cache.");
390        }
391    }
392    
393    /**
394     * Get the cache file to write for the corresponding page.
395     * @param root the cache root folder.
396     * @param pagePath the page path.
397     * @return a valid file.
398     */
399    protected File getFile(File root, String pagePath)
400    {
401        File file = new File(root, pagePath);
402        
403        if (!SiteCacheHelper.isValid(file))
404        {
405            String validPath = SiteCacheHelper.getHashedFilePath(pagePath);
406            
407            file = new File(root, validPath);
408        }
409        
410        return file;
411    }
412    
413}