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                    BackOfficeRequestHelper.switchOnMaintenanceIfNeeded(cmsResponse);
221                    throw new ServiceUnavailableException("Site down for URL " + cmsRequest.getURI());
222                    
223                default:
224                    _log("Unable to get resource '" + page + "'. Status code is " + statusCode);
225                    throw new ProcessingException("Unable to get URL '" + page + "' at URL '" + cmsRequest.getURI() + "'. Status code is " + statusCode);
226            }
227        }
228        finally
229        {
230            // Whatever happens, unlock the page.
231            _getCacheAccessManager().unlock(page);
232            
233            if (httpClient != null)
234            {
235                httpClient.close();
236            }
237        }
238    }
239    
240    private String _encodeRequestedUrl(Request request)
241    {
242        String requestedURI = (String) request.getAttribute("requestedURI");
243        
244        // Transmit parameters
245        StringBuilder transmittedParameters = new StringBuilder();
246        boolean first = true;
247        Enumeration<String> parameterNames = request.getParameterNames();
248        
249        while (parameterNames.hasMoreElements())
250        {
251            if (first)
252            {
253                transmittedParameters.append("?");
254                first = false;
255            }
256            else
257            {
258                transmittedParameters.append("&");
259            }
260            String parameterName = parameterNames.nextElement();
261            transmittedParameters.append(parameterName);
262            transmittedParameters.append("=");
263            transmittedParameters.append(URIUtils.encodeParameter(request.getParameter(parameterName)));
264        }
265        
266        if (!first)
267        {
268            // request.getAttribute("requestedURI") is already encoded
269            requestedURI += URIUtils.encodeParameter(transmittedParameters.toString());
270        }
271        
272        return requestedURI;
273    }
274    
275    private void _log(String message)
276    {
277        if (getLogger().isDebugEnabled())
278        {
279            getLogger().debug(message);
280        }
281    }
282    
283    private void _redirect(HttpResponse cmsResponse, Redirector redirector, String page, boolean permanent) throws Exception
284    {
285        String location = cmsResponse.getFirstHeader("Location").getValue();
286        
287        if (permanent && redirector instanceof PermanentRedirector)
288        {
289            ((PermanentRedirector) redirector).permanentRedirect(false, location);
290        }
291        else
292        {
293            redirector.redirect(false, location);
294        }
295        
296        _log("Redirect '" + page + "' to '" + location + "'");
297    }
298    
299    /**
300     * Copy some response headers from the back-office.
301     * @param request the front-office client request.
302     * @param response the front-office client response.
303     * @param cmsResponse the response from the back-office.
304     * @param names the header names.
305     */
306    protected void _copyHeaders(Request request, Response response, HttpResponse cmsResponse, String[] names)
307    {
308        _transposeCookies(request, response, cmsResponse);
309        
310        for (String name : names)
311        {
312            Header[] headers = cmsResponse.getHeaders(name);
313            for (Header header : headers)
314            {
315                String value = header.getValue();
316                
317                response.addHeader(name, value);
318            }
319        }
320    }
321    
322    private void _transposeCookies(Request request, Response response, HttpResponse cmsResponse)
323    {
324        Header[] headers = cmsResponse.getHeaders("Set-Cookie");
325        for (Header header : headers)
326        {
327            String value = header.getValue();
328            
329            List<HttpCookie> cookies = HttpCookie.parse(value);
330            for (HttpCookie cookie : cookies)
331            {
332                String cookieName = cookie.getName();
333
334                if ("JSESSIONID".equals(cookieName))
335                {
336                    if (getLogger().isWarnEnabled())
337                    {
338                        getLogger().warn("Receiving JSESSIONID cookie from the back-office.");
339                    }
340                    cookieName = __BACKOFFICE_JSESSION_ID;
341                }
342                
343                Cookie newCookie = response.createCookie(cookieName, cookie.getValue());
344                javax.servlet.http.Cookie newHttpCookie = ((org.apache.cocoon.environment.http.HttpCookie) newCookie).getServletCookie();
345                newHttpCookie.setComment(cookie.getComment());
346                if (cookie.getDomain() != null)
347                {
348                    newHttpCookie.setDomain(cookie.getDomain());
349                }
350                newHttpCookie.setMaxAge((int) cookie.getMaxAge());
351                newHttpCookie.setPath(StringUtils.defaultIfEmpty(request.getContextPath(), "/"));
352                newHttpCookie.setSecure(request.isSecure());
353                newHttpCookie.setHttpOnly(cookie.isHttpOnly());
354                response.addCookie(newCookie);
355            }
356        }
357    }
358
359    /**
360     * Read the page from the back-office response and write it into the cache.
361     * @param decodedPage the page path.
362     * @param cmsResponse the back-office response, containing the page.
363     * @throws IOException if an error occurs writing the page into the cache.
364     */
365    protected void _writePageOnDisk(String decodedPage, HttpResponse cmsResponse) throws IOException
366    {
367        if (getLogger().isDebugEnabled())
368        {
369            getLogger().debug("The page is cacheable, writing into the cache: " + decodedPage);
370        }
371        
372        File root = SiteCacheHelper.getRootCache();
373        
374        File file = getFile(root, decodedPage);
375        if (!file.exists() && cmsResponse != null && cmsResponse.getEntity() != null)
376        {
377            file.getParentFile().mkdirs();
378            
379            try (FileOutputStream fos = new FileOutputStream(file);) 
380            {
381                cmsResponse.getEntity().writeTo(fos);
382            }
383            catch (IOException e)
384            {
385                throw new IOException("Error writing the file '" + file.getAbsolutePath() + "' into the cache, check that the directory is writable.", e);
386            }
387        }
388        
389        if (getLogger().isDebugEnabled())
390        {
391            getLogger().debug("Page " + decodedPage + " written into the cache.");
392        }
393    }
394    
395    /**
396     * Get the cache file to write for the corresponding page.
397     * @param root the cache root folder.
398     * @param pagePath the page path.
399     * @return a valid file.
400     */
401    protected File getFile(File root, String pagePath)
402    {
403        File file = new File(root, pagePath);
404        
405        if (!SiteCacheHelper.isValid(file))
406        {
407            String validPath = SiteCacheHelper.getHashedFilePath(pagePath);
408            
409            file = new File(root, validPath);
410        }
411        
412        return file;
413    }
414    
415}