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.lang3.StringUtils;
040import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
041import org.apache.hc.core5.http.ClassicHttpRequest;
042import org.apache.hc.core5.http.ClassicHttpResponse;
043import org.apache.hc.core5.http.Header;
044import org.apache.hc.core5.http.HttpEntity;
045import org.apache.hc.core5.http.HttpResponse;
046
047import org.ametys.core.user.UserIdentity;
048import org.ametys.core.util.URIUtils;
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        _log("Generating the resource " + page);
122        
123        Site site = (Site) request.getAttribute("site");
124        if (site != null)
125        {
126            _getCacheAccessCounter().increaseAskedResources(site.getName());
127        }
128        
129        CloseableHttpClient httpClient = null;
130        try
131        {
132            // If the request was previously done (to test cachability), get it back
133            httpClient = (CloseableHttpClient) request.getAttribute("http-client");
134            ClassicHttpRequest cmsRequest = (ClassicHttpRequest) request.getAttribute("cms-request");
135            ClassicHttpResponse cmsResponse = (ClassicHttpResponse) request.getAttribute("cms-response");
136            
137            // If page was already known as non cacheable, we should do the request by now
138            if (cmsResponse == null)
139            {
140                // FIXME CMS-12715 Reuse the same client for all request from site to cms
141                httpClient = BackOfficeRequestHelper.getHttpClient();
142                cmsRequest = BackOfficeRequestHelper.getRequest(objectModel, page, _requestHeaderEP);
143                cmsResponse = httpClient.executeOpen(null, cmsRequest, null);
144                
145                request.setAttribute("cms-request", cmsRequest);
146                request.setAttribute("cms-response", cmsResponse);
147                request.setAttribute("http-client", httpClient);
148            }
149            
150            // Copy some headers in the response.
151            _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"});
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.getCode();
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(page, 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 206:
182                case 207:
183                case 304:
184                    ((org.apache.cocoon.environment.http.HttpResponse) response).setStatus(statusCode);
185                    
186                    // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
187                    httpClient = null;
188                    return EMPTY_MAP;
189                    
190                case 301: // Permanent redirection
191                    _redirect(cmsResponse, redirector, page, true);
192                    return null;
193                    
194                case 302: // Redirection
195                    _redirect(cmsResponse, redirector, page, false);
196                    return null;
197                    
198                case 401: // authorization required
199                    if (site == null)
200                    {
201                        throw new IllegalStateException("Cannot authenticate outsite a site");
202                    }
203                    
204                    SiteUrl siteUrl = site.getSiteUrls().get(0);
205                    
206                    redirector.redirect(false, siteUrl.getBaseServerPath(request) + siteUrl.getServerPath() + "/_authenticate?requestedURL=" + _encodeRequestedUrl(request));
207                    _log("Resource '" + page + "' needs authentication");
208                    return null;
209                    
210                case 403: // access denied
211                    UserIdentity user = site != null ? FrontAuthenticateAction.getUserIdentityFromSession(request, site.getName()) : null;
212                    String userStr = user != null ? "user " + user.toString() : " anonymous user";
213                    
214                    _log("Access denied for resource '" + page + "' for " + userStr);
215                    throw new AccessDeniedException("Access denied for " + userStr + " for URL " + cmsRequest.getUri());
216                    
217                case 404: // not found
218                    _log("Resource not found '" + page + "'");
219                    throw new ResourceNotFoundException("Resource not found for URL " + cmsRequest.getUri());
220                    
221                case 503: // Incomplete configuration or site down.
222                    _log("Site down for URL '" + page + "'");
223                    BackOfficeRequestHelper.switchOnMaintenanceIfNeeded(cmsResponse);
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)
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        
252        while (parameterNames.hasMoreElements())
253        {
254            if (first)
255            {
256                transmittedParameters.append("?");
257                first = false;
258            }
259            else
260            {
261                transmittedParameters.append("&");
262            }
263            String parameterName = parameterNames.nextElement();
264            transmittedParameters.append(parameterName);
265            transmittedParameters.append("=");
266            transmittedParameters.append(URIUtils.encodeParameter(request.getParameter(parameterName)));
267        }
268        
269        if (!first)
270        {
271            // request.getAttribute("requestedURI") is already encoded
272            requestedURI += URIUtils.encodeParameter(transmittedParameters.toString());
273        }
274        
275        return requestedURI;
276    }
277    
278    private void _log(String message)
279    {
280        if (getLogger().isDebugEnabled())
281        {
282            getLogger().debug(message);
283        }
284    }
285    
286    private void _redirect(HttpResponse cmsResponse, Redirector redirector, String page, boolean permanent) throws Exception
287    {
288        String location = cmsResponse.getFirstHeader("Location").getValue();
289        
290        if (permanent && redirector instanceof PermanentRedirector)
291        {
292            ((PermanentRedirector) redirector).permanentRedirect(false, location);
293        }
294        else
295        {
296            redirector.redirect(false, location);
297        }
298        
299        _log("Redirect '" + page + "' to '" + location + "'");
300    }
301    
302    /**
303     * Copy some response headers from the back-office.
304     * @param request the front-office client request.
305     * @param response the front-office client response.
306     * @param cmsResponse the response from the back-office.
307     * @param names the header names.
308     */
309    protected void _copyHeaders(Request request, Response response, HttpResponse cmsResponse, String[] names)
310    {
311        _transposeCookies(request, response, cmsResponse);
312        
313        for (String name : names)
314        {
315            Header[] headers = cmsResponse.getHeaders(name);
316            for (Header header : headers)
317            {
318                String value = header.getValue();
319                
320                response.addHeader(name, value);
321            }
322        }
323    }
324    
325    private void _transposeCookies(Request request, Response response, HttpResponse cmsResponse)
326    {
327        Header[] headers = cmsResponse.getHeaders("Set-Cookie");
328        for (Header header : headers)
329        {
330            String value = header.getValue();
331            
332            List<HttpCookie> cookies = HttpCookie.parse(value);
333            for (HttpCookie cookie : cookies)
334            {
335                String cookieName = cookie.getName();
336
337                if ("JSESSIONID".equals(cookieName))
338                {
339                    if (getLogger().isWarnEnabled())
340                    {
341                        getLogger().warn("Receiving JSESSIONID cookie from the back-office.");
342                    }
343                    cookieName = __BACKOFFICE_JSESSION_ID;
344                }
345                
346                Cookie newCookie = response.createCookie(cookieName, cookie.getValue());
347                javax.servlet.http.Cookie newHttpCookie = ((org.apache.cocoon.environment.http.HttpCookie) newCookie).getServletCookie();
348                newHttpCookie.setComment(cookie.getComment());
349                if (cookie.getDomain() != null)
350                {
351                    newHttpCookie.setDomain(cookie.getDomain());
352                }
353                newHttpCookie.setMaxAge((int) cookie.getMaxAge());
354                newHttpCookie.setPath(StringUtils.defaultIfEmpty(request.getContextPath(), "/"));
355                newHttpCookie.setSecure(request.isSecure());
356                newHttpCookie.setHttpOnly(cookie.isHttpOnly());
357                response.addCookie(newCookie);
358            }
359        }
360    }
361
362    /**
363     * Read the page from the back-office response and write it into the cache.
364     * @param decodedPage the page path.
365     * @param cmsResponse the back-office response, containing the page.
366     * @throws IOException if an error occurs writing the page into the cache.
367     */
368    protected void _writePageOnDisk(String decodedPage, ClassicHttpResponse cmsResponse) throws IOException
369    {
370        if (getLogger().isDebugEnabled())
371        {
372            getLogger().debug("The page is cacheable, writing into the cache: " + decodedPage);
373        }
374        
375        File root = SiteCacheHelper.getRootCache();
376        
377        File file = getFile(root, decodedPage);
378        if (!file.exists() && cmsResponse != null && cmsResponse.getEntity() != null)
379        {
380            file.getParentFile().mkdirs();
381            
382            try (FileOutputStream fos = new FileOutputStream(file);
383                    HttpEntity entity = cmsResponse.getEntity())
384            {
385                entity.writeTo(fos);
386            }
387            catch (IOException e)
388            {
389                throw new IOException("Error writing the file '" + file.getAbsolutePath() + "' into the cache, check that the directory is writable.", e);
390            }
391        }
392        
393        if (getLogger().isDebugEnabled())
394        {
395            getLogger().debug("Page " + decodedPage + " written into the cache.");
396        }
397    }
398    
399    /**
400     * Get the cache file to write for the corresponding page.
401     * @param root the cache root folder.
402     * @param pagePath the page path.
403     * @return a valid file.
404     */
405    protected File getFile(File root, String pagePath)
406    {
407        File file = new File(root, pagePath);
408        
409        if (!SiteCacheHelper.isValid(file))
410        {
411            String validPath = SiteCacheHelper.getHashedFilePath(pagePath);
412            
413            file = new File(root, validPath);
414        }
415        
416        return file;
417    }
418    
419}