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 some headers in the response.
148            _copyHeaders(request, response, cmsResponse, new String[]{"Content-Disposition", "Accept-Ranges", "Content-Range", "Content-Type", "Content-Length", "Cache-Control", "Allow", "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "ETag", "Last-Modified", "Ametys-Dispatched"});
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 206:
179                case 207:
180                case 304:
181                    ((org.apache.cocoon.environment.http.HttpResponse) response).setStatus(statusCode);
182                    
183                    // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
184                    httpClient = null;
185                    return EMPTY_MAP;
186                    
187                case 301: // Permanent redirection
188                    _redirect(cmsResponse, redirector, page, true);
189                    return null;
190                    
191                case 302: // Redirection
192                    _redirect(cmsResponse, redirector, page, false);
193                    return null;
194                    
195                case 401: // authorization required
196                    if (site == null)
197                    {
198                        throw new IllegalStateException("Cannot authenticate outsite a site");
199                    }
200                    
201                    SiteUrl siteUrl = site.getSiteUrls().get(0);
202                    
203                    redirector.redirect(false, siteUrl.getBaseServerPath(request) + siteUrl.getServerPath() + "/_authenticate?requestedURL=" + _encodeRequestedUrl(request));
204                    _log("Resource '" + page + "' needs authentication");
205                    return null;
206                    
207                case 403: // access denied
208                    UserIdentity user = site != null ? FrontAuthenticateAction.getUserIdentityFromSession(request, site.getName()) : null;
209                    String userStr = user != null ? "user " + user.toString() : " anonymous user";
210                    
211                    _log("Access denied for resource '" + page + "' for " + userStr);
212                    throw new AccessDeniedException("Access denied for " + userStr + " for URL " + cmsRequest.getURI());
213                    
214                case 404: // not found
215                    _log("Resource not found '" + page + "'");
216                    throw new ResourceNotFoundException("Resource not found for URL " + cmsRequest.getURI());
217                    
218                case 503: // Incomplete configuration or site down.
219                    _log("Site down for URL '" + page + "'");
220                    throw new ServiceUnavailableException("Site down for URL " + cmsRequest.getURI());
221                    
222                default:
223                    _log("Unable to get resource '" + page + "'. Status code is " + statusCode);
224                    throw new ProcessingException("Unable to get URL '" + page + "' at URL '" + cmsRequest.getURI() + "'. Status code is " + statusCode);
225            }
226        }
227        finally
228        {
229            // Whatever happens, unlock the page.
230            _getCacheAccessManager().unlock(page);
231            
232            if (httpClient != null)
233            {
234                httpClient.close();
235            }
236        }
237    }
238    
239    private String _encodeRequestedUrl(Request request)
240    {
241        String requestedURI = (String) request.getAttribute("requestedURI");
242        
243        // Transmit parameters
244        StringBuilder transmittedParameters = new StringBuilder();
245        boolean first = true;
246        Enumeration<String> parameterNames = request.getParameterNames();
247        
248        while (parameterNames.hasMoreElements())
249        {
250            if (first)
251            {
252                transmittedParameters.append("?");
253                first = false;
254            }
255            else
256            {
257                transmittedParameters.append("&");
258            }
259            String parameterName = parameterNames.nextElement();
260            transmittedParameters.append(parameterName);
261            transmittedParameters.append("=");
262            transmittedParameters.append(URIUtils.encodeParameter(request.getParameter(parameterName)));
263        }
264        
265        if (!first)
266        {
267            // request.getAttribute("requestedURI") is already encoded
268            requestedURI += URIUtils.encodeParameter(transmittedParameters.toString());
269        }
270        
271        return requestedURI;
272    }
273    
274    private void _log(String message)
275    {
276        if (getLogger().isDebugEnabled())
277        {
278            getLogger().debug(message);
279        }
280    }
281    
282    private void _redirect(HttpResponse cmsResponse, Redirector redirector, String page, boolean permanent) throws Exception
283    {
284        String location = cmsResponse.getFirstHeader("Location").getValue();
285        
286        if (permanent && redirector instanceof PermanentRedirector)
287        {
288            ((PermanentRedirector) redirector).permanentRedirect(false, location);
289        }
290        else
291        {
292            redirector.redirect(false, location);
293        }
294        
295        _log("Redirect '" + page + "' to '" + location + "'");
296    }
297    
298    /**
299     * Copy some response headers from the back-office.
300     * @param request the front-office client request.
301     * @param response the front-office client response.
302     * @param cmsResponse the response from the back-office.
303     * @param names the header names.
304     */
305    protected void _copyHeaders(Request request, Response response, HttpResponse cmsResponse, String[] names)
306    {
307        _transposeCookies(request, response, cmsResponse);
308        
309        for (String name : names)
310        {
311            Header[] headers = cmsResponse.getHeaders(name);
312            for (Header header : headers)
313            {
314                String value = header.getValue();
315                
316                response.addHeader(name, value);
317            }
318        }
319    }
320    
321    private void _transposeCookies(Request request, Response response, HttpResponse cmsResponse)
322    {
323        Header[] headers = cmsResponse.getHeaders("Set-Cookie");
324        for (Header header : headers)
325        {
326            String value = header.getValue();
327            
328            List<HttpCookie> cookies = HttpCookie.parse(value);
329            for (HttpCookie cookie : cookies)
330            {
331                String cookieName = cookie.getName();
332
333                if ("JSESSIONID".equals(cookieName))
334                {
335                    if (getLogger().isWarnEnabled())
336                    {
337                        getLogger().warn("Receiving JSESSIONID cookie from the back-office.");
338                    }
339                    cookieName = __BACKOFFICE_JSESSION_ID;
340                }
341                
342                Cookie newCookie = response.createCookie(cookieName, cookie.getValue());
343                javax.servlet.http.Cookie newHttpCookie = ((org.apache.cocoon.environment.http.HttpCookie) newCookie).getServletCookie();
344                newHttpCookie.setComment(cookie.getComment());
345                if (cookie.getDomain() != null)
346                {
347                    newHttpCookie.setDomain(cookie.getDomain());
348                }
349                newHttpCookie.setMaxAge((int) cookie.getMaxAge());
350                newHttpCookie.setPath(StringUtils.defaultIfEmpty(request.getContextPath(), "/"));
351                newHttpCookie.setSecure(request.isSecure());
352                newHttpCookie.setHttpOnly(cookie.isHttpOnly());
353                response.addCookie(newCookie);
354            }
355        }
356    }
357
358    /**
359     * Read the page from the back-office response and write it into the cache.
360     * @param decodedPage the page path.
361     * @param cmsResponse the back-office response, containing the page.
362     * @throws IOException if an error occurs writing the page into the cache.
363     */
364    protected void _writePageOnDisk(String decodedPage, HttpResponse cmsResponse) throws IOException
365    {
366        if (getLogger().isDebugEnabled())
367        {
368            getLogger().debug("The page is cacheable, writing into the cache: " + decodedPage);
369        }
370        
371        File root = SiteCacheHelper.getRootCache();
372        
373        File file = getFile(root, decodedPage);
374        if (!file.exists() && cmsResponse != null && cmsResponse.getEntity() != null)
375        {
376            file.getParentFile().mkdirs();
377            
378            try (FileOutputStream fos = new FileOutputStream(file);) 
379            {
380                cmsResponse.getEntity().writeTo(fos);
381            }
382            catch (IOException e)
383            {
384                throw new IOException("Error writing the file '" + file.getAbsolutePath() + "' into the cache, check that the directory is writable.", e);
385            }
386        }
387        
388        if (getLogger().isDebugEnabled())
389        {
390            getLogger().debug("Page " + decodedPage + " written into the cache.");
391        }
392    }
393    
394    /**
395     * Get the cache file to write for the corresponding page.
396     * @param root the cache root folder.
397     * @param pagePath the page path.
398     * @return a valid file.
399     */
400    protected File getFile(File root, String pagePath)
401    {
402        File file = new File(root, pagePath);
403        
404        if (!SiteCacheHelper.isValid(file))
405        {
406            String validPath = SiteCacheHelper.getHashedFilePath(pagePath);
407            
408            file = new File(root, validPath);
409        }
410        
411        return file;
412    }
413    
414}