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.io.UnsupportedEncodingException;
022import java.net.HttpCookie;
023import java.net.URI;
024import java.net.URLEncoder;
025import java.util.Enumeration;
026import java.util.List;
027import java.util.Map;
028import java.util.regex.Pattern;
029
030import org.apache.avalon.framework.parameters.Parameters;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.thread.ThreadSafe;
034import org.apache.cocoon.ProcessingException;
035import org.apache.cocoon.ResourceNotFoundException;
036import org.apache.cocoon.acting.ServiceableAction;
037import org.apache.cocoon.environment.Cookie;
038import org.apache.cocoon.environment.ObjectModelHelper;
039import org.apache.cocoon.environment.PermanentRedirector;
040import org.apache.cocoon.environment.Redirector;
041import org.apache.cocoon.environment.Request;
042import org.apache.cocoon.environment.Response;
043import org.apache.commons.lang.StringUtils;
044import org.apache.http.Header;
045import org.apache.http.HttpResponse;
046import org.apache.http.client.methods.HttpUriRequest;
047import org.apache.http.impl.client.CloseableHttpClient;
048
049import org.ametys.core.user.UserIdentity;
050import org.ametys.plugins.site.Site;
051import org.ametys.plugins.site.SiteUrl;
052import org.ametys.plugins.site.proxy.BackOfficeRequestProxy;
053import org.ametys.plugins.site.proxy.BackOfficeRequestProxyExtensionPoint;
054import org.ametys.runtime.authentication.AccessDeniedException;
055import org.ametys.runtime.exception.ServiceUnavailableException;
056
057/**
058 * Call the BO for getting a page content and stores it in the local cache.
059 */
060public class GeneratePageAction extends ServiceableAction implements ThreadSafe
061{
062    static final String __BACKOFFICE_JSESSION_ID = "JSESSIONID-Ametys";
063    
064    private static final Pattern __COOKIE_PATH_PATTERN = Pattern.compile(".*(Path=[^;]+).*");
065    
066    private CacheAccessManager _cacheAccess;
067    private CacheAccessCounter _cacheAccessCounter;
068    private BackOfficeRequestProxyExtensionPoint _requestHeaderEP;
069    
070    @Override
071    public void service(ServiceManager sManager) throws ServiceException
072    {
073        super.service(sManager);
074        _requestHeaderEP = (BackOfficeRequestProxyExtensionPoint) sManager.lookup(BackOfficeRequestProxyExtensionPoint.ROLE);
075    }
076
077    /**
078     * Get the cache access counter
079     * @return the CacheAccessCounter
080     */
081    protected CacheAccessCounter _getCacheAccessCounter()
082    {
083        if (_cacheAccessCounter == null)
084        {
085            try
086            {
087                _cacheAccessCounter = (CacheAccessCounter) manager.lookup(CacheAccessCounter.ROLE);
088            }
089            catch (ServiceException e)
090            {
091                throw new IllegalStateException("Cannot get CacheAccessCounter", e);
092            }
093        }
094        return _cacheAccessCounter;
095    }
096    
097    /**
098     * Get the cache access manager
099     * @return the CacheAccessManager
100     */
101    protected CacheAccessManager _getCacheAccessManager()
102    {
103        if (_cacheAccess == null)
104        {
105            try
106            {
107                _cacheAccess = (CacheAccessManager) manager.lookup(CacheAccessManager.ROLE);
108            }
109            catch (ServiceException e)
110            {
111                throw new IllegalStateException("Cannot get CacheAccessManager", e);
112            }
113        }
114        return _cacheAccess;
115    }
116    
117    @Override
118    public Map act(Redirector redirector, org.apache.cocoon.environment.SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
119    {
120        Request request = ObjectModelHelper.getRequest(objectModel);
121        Response response = ObjectModelHelper.getResponse(objectModel);
122
123        String page = parameters.getParameter("page");
124        String decodedPage = new URI(page).getPath();
125        _log("Generating the resource " + page);
126        
127        Site site = (Site) request.getAttribute("site");
128        if (site != null)
129        {
130            _getCacheAccessCounter().increaseAskedResources(site.getName());
131        }
132        
133        CloseableHttpClient httpClient = null;
134        try
135        {
136            // If the request was previously done (to test cachability), get it back
137            httpClient = (CloseableHttpClient) request.getAttribute("http-client");
138            HttpUriRequest cmsRequest = (HttpUriRequest) request.getAttribute("cms-request");
139            HttpResponse cmsResponse = (HttpResponse) request.getAttribute("cms-response");
140            
141            // If page was already known as non cacheable, we should do the request by now
142            if (cmsResponse == null)
143            {
144                httpClient = BackOfficeRequestHelper.getHttpClient();
145                cmsRequest = BackOfficeRequestHelper.getRequest(objectModel, page, _requestHeaderEP);
146                cmsResponse = httpClient.execute(cmsRequest);
147                
148                request.setAttribute("cms-request", cmsRequest);
149                request.setAttribute("cms-response", cmsResponse);
150                request.setAttribute("http-client", httpClient);
151            }
152            
153            // Copy the Set-Cookie headers in the response.
154            _copyHeaders(request, response, cmsResponse, new String[]{"Content-Disposition", "Content-Length", "Cache-Control", "Allow", "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "ETag", "Last-Modified"});
155            
156            for (String requestId : _requestHeaderEP.getExtensionsIds())
157            {
158                BackOfficeRequestProxy boRequestComponent = _requestHeaderEP.getExtension(requestId);
159                boRequestComponent.handleBackOfficeResponse(response, cmsResponse);
160            }
161            
162            // Handle the request
163            int statusCode = cmsResponse.getStatusLine().getStatusCode();
164            switch (statusCode)
165            {
166                case 200:
167                    Header header = cmsResponse.getFirstHeader("X-Ametys-Cacheable");
168                    Object editionMode = request.getAttribute(GetSiteAction.EDITION_URI);
169                    if (header != null && "true".equals(header.getValue()) && !"true".equals(editionMode))
170                    {
171                        _writePageOnDisk(decodedPage, cmsResponse);
172                        
173                        _log("Succeed to generate cacheable resource '" + page + "'");
174                        
175                        return null;
176                    }
177                    
178                    // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
179                    httpClient = null;
180                    _log("Succeed to generate uncacheable resource '" + page + "'");
181                    
182                    return EMPTY_MAP;
183                case 204:
184                case 207:
185                case 304:
186                    ((org.apache.cocoon.environment.http.HttpResponse) response).setStatus(statusCode);
187                    
188                    // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
189                    httpClient = null;
190                    return EMPTY_MAP;
191                    
192                case 301: // Permanent redirection
193                    _redirect(cmsResponse, redirector, page, true);
194                    return null;
195                    
196                case 302: // Redirection
197                    _redirect(cmsResponse, redirector, page, false);
198                    return null;
199                    
200                case 401: // authorization required
201                    if (site == null)
202                    {
203                        throw new IllegalStateException("Cannot authenticate outsite a site");
204                    }
205                    SiteUrl siteUrl = site.getSiteUrls().get(0);
206                    
207                    redirector.redirect(false, siteUrl.getBaseServerPath(request) + siteUrl.getServerPath() + "/_authenticate?requestedURL=" + _encodeRequestedUrl(request));
208                    _log("Resource '" + page + "' needs authentication");
209                    return null;
210                    
211                case 403: // access denied
212                    UserIdentity user = site != null ? FrontAuthenticateAction.getUserIdentityFromSession(request, site.getName()) : null;
213                    String userStr = user != null ? "user " + user.toString() : " anonymous user";
214                    
215                    _log("Access denied for resource '" + page + "' for " + userStr);
216                    throw new AccessDeniedException("Access denied for " + userStr + " for URL " + cmsRequest.getURI());
217                    
218                case 404: // not found
219                    _log("Resource not found '" + page + "'");
220                    throw new ResourceNotFoundException("Resource not found for URL " + cmsRequest.getURI());
221                    
222                case 503: // Incomplete configuration or site down.
223                    _log("Site down for URL '" + page + "'");
224                    throw new ServiceUnavailableException("Site down for URL " + cmsRequest.getURI());
225                    
226                default:
227                    _log("Unable to get resource '" + page + "'. Status code is " + statusCode);
228                    throw new ProcessingException("Unable to get URL '" + page + "' at URL '" + cmsRequest.getURI() + "'. Status code is " + statusCode);
229            }
230        }
231        finally
232        {
233            // Whatever happens, unlock the page.
234            _getCacheAccessManager().unlock(page);
235            
236            if (httpClient != null)
237            {
238                httpClient.close();
239            }
240        }
241    }
242    
243    private String _encodeRequestedUrl(Request request) throws UnsupportedEncodingException
244    {
245        String requestedURI = (String) request.getAttribute("requestedURI");
246        
247        // Transmit parameters
248        StringBuilder transmittedParameters = new StringBuilder();
249        boolean first = true;
250        Enumeration<String> parameterNames = request.getParameterNames();
251        while (parameterNames.hasMoreElements())
252        {
253            if (first)
254            {
255                transmittedParameters.append("?");
256                first = false;
257            }
258            else
259            {
260                transmittedParameters.append("&");
261            }
262            String parameterName = parameterNames.nextElement();
263            transmittedParameters.append(parameterName);
264            transmittedParameters.append("=");
265            transmittedParameters.append(URLEncoder.encode(request.getParameter(parameterName), "UTF-8"));
266        }
267        if (!first)
268        {
269            requestedURI += URLEncoder.encode(transmittedParameters.toString(), "UTF-8"); // request.getAttribute("requestedURI") is already encoded
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}