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