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.URI;
023import java.net.URLEncoder;
024import java.util.Enumeration;
025import java.util.Map;
026import java.util.regex.Matcher;
027import java.util.regex.Pattern;
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.ObjectModelHelper;
037import org.apache.cocoon.environment.PermanentRedirector;
038import org.apache.cocoon.environment.Redirector;
039import org.apache.cocoon.environment.Request;
040import org.apache.cocoon.environment.Response;
041import org.apache.commons.lang.StringUtils;
042import org.apache.http.Header;
043import org.apache.http.HttpResponse;
044import org.apache.http.client.methods.HttpUriRequest;
045import org.apache.http.impl.client.CloseableHttpClient;
046
047import org.ametys.core.user.UserIdentity;
048import org.ametys.plugins.site.Site;
049import org.ametys.plugins.site.SiteUrl;
050import org.ametys.plugins.site.headers.RequestHeaderExtensionPoint;
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 static final Pattern __COOKIE_PATH_PATTERN = Pattern.compile(".*(Path=[^;]+).*");
062    
063    private CacheAccessManager _cacheAccess;
064    private CacheAccessCounter _cacheAccessCounter;
065    private RequestHeaderExtensionPoint _requestHeaderEP;
066    
067    @Override
068    public void service(ServiceManager sManager) throws ServiceException
069    {
070        super.service(sManager);
071        _cacheAccess = (CacheAccessManager) sManager.lookup(CacheAccessManager.ROLE);
072        _cacheAccessCounter = (CacheAccessCounter) sManager.lookup(CacheAccessCounter.ROLE);
073        _requestHeaderEP = (RequestHeaderExtensionPoint) sManager.lookup(RequestHeaderExtensionPoint.ROLE);
074    }
075    
076    @Override
077    public Map act(Redirector redirector, org.apache.cocoon.environment.SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
078    {
079        Request request = ObjectModelHelper.getRequest(objectModel);
080        Response response = ObjectModelHelper.getResponse(objectModel);
081        
082        String page = parameters.getParameter("page");
083        _log("Generating the resource " + page);
084        
085        Site site = (Site) request.getAttribute("site");
086        if (site != null)
087        {
088            _cacheAccessCounter.increaseAskedResources(site.getName());
089        }
090        
091        CloseableHttpClient httpClient = null;
092        try
093        {
094            // If the request was previously done (to test cachability), get it back
095            httpClient = (CloseableHttpClient) request.getAttribute("http-client");
096            HttpUriRequest cmsRequest = (HttpUriRequest) request.getAttribute("cms-request");
097            HttpResponse cmsResponse = (HttpResponse) request.getAttribute("cms-response");
098            
099            // If page was already known as non cacheable, we should do the request by now
100            if (cmsResponse == null)
101            {
102                httpClient = BackOfficeRequestHelper.getHttpClient();
103                cmsRequest = BackOfficeRequestHelper.getRequest(objectModel, page, _requestHeaderEP);
104                cmsResponse = httpClient.execute(cmsRequest);
105                
106                request.setAttribute("cms-request", cmsRequest);
107                request.setAttribute("cms-response", cmsResponse);
108                request.setAttribute("http-client", httpClient);
109            }
110            
111            // Copy the Set-Cookie headers in the response.
112            _copyHeaders(request, response, cmsResponse, new String[]{"Set-Cookie", "Set-Cookie2", "Content-Disposition", "Cache-Control", "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials"});
113            
114            // Handle the request
115            int statusCode = cmsResponse.getStatusLine().getStatusCode();
116            switch (statusCode)
117            {
118                case 200:
119                    Header header = cmsResponse.getFirstHeader("X-Ametys-Cacheable");
120                    if (header != null && "true".equals(header.getValue()))
121                    {
122                        String decodedPage = new URI(page).getPath();
123                        _writePageOnDisk(decodedPage, cmsResponse);
124                        
125                        _log("Succeed to generate cacheable resource '" + page + "'");
126                        
127                        return null;
128                    }
129                    else
130                    {
131                        // Set httpClient to null because we don't want it to be shut down in the finally statement, but by the CMSResponseReader to follow
132                        httpClient = null;
133                        _log("Succeed to generate uncacheable resource '" + page + "'");
134                        
135                        return EMPTY_MAP;
136                    }
137                case 301: // Permanent redirection
138                    _redirect(cmsResponse, redirector, page, true);
139                    return null;
140                    
141                case 302: // Redirection
142                    _redirect(cmsResponse, redirector, page, false);
143                    return null;
144                    
145                case 401: // authorization required
146                    if (site == null)
147                    {
148                        throw new IllegalStateException("Cannot authenticate outsite a site");
149                    }
150                    SiteUrl siteUrl = site.getSiteUrls().get(0);
151                    
152                    redirector.redirect(false, siteUrl.getBaseServerPath(request) + siteUrl.getServerPath() + "/_authenticate?requestedURL=" + _encodeRequestedUrl(request));
153                    _log("Resource '" + page + "' needs authentication");
154                    return null;
155                    
156                case 403: // access denied
157                    UserIdentity user = site != null ? FrontAuthenticateAction.getUserIdentityFromSession(request, site.getName()) : null;
158                    String userStr = user != null ? "user " + user.toString() : " anonymous user";
159                    
160                    _log("Access denied for resource '" + page + "' for " + userStr);
161                    throw new AccessDeniedException("Access denied for " + userStr + " for URL " + cmsRequest.getURI());
162                    
163                case 404: // not found
164                    _log("Resource not found '" + page + "'");
165                    throw new ResourceNotFoundException("Resource not found for URL " + cmsRequest.getURI());
166                    
167                case 503: // Incomplete configuration or site down.
168                    _log("Site down for URL '" + page + "'");
169                    throw new ServiceUnavailableException("Site down for URL " + cmsRequest.getURI());
170                    
171                default:
172                    _log("Unable to get resource '" + page + "'. Status code is " + statusCode);
173                    throw new ProcessingException("Unable to get URL '" + page + "' at URL '" + cmsRequest.getURI() + "'. Status code is " + statusCode);
174            }
175        }
176        finally
177        {
178            // Whatever happens, unlock the page.
179            _cacheAccess.unlock(page);
180            
181            if (httpClient != null)
182            {
183                httpClient.close();
184            }
185        }
186    }
187    
188    private String _encodeRequestedUrl(Request request) throws UnsupportedEncodingException
189    {
190        String requestedURI = (String) request.getAttribute("requestedURI");
191        
192        // Transmit parameters
193        StringBuilder transmittedParameters = new StringBuilder();
194        boolean first = true;
195        Enumeration<String> parameterNames = request.getParameterNames();
196        while (parameterNames.hasMoreElements())
197        {
198            if (first)
199            {
200                transmittedParameters.append("?");
201                first = false;
202            }
203            else
204            {
205                transmittedParameters.append("&");
206            }
207            String parameterName = parameterNames.nextElement();
208            transmittedParameters.append(parameterName);
209            transmittedParameters.append("=");
210            transmittedParameters.append(URLEncoder.encode(request.getParameter(parameterName), "UTF-8"));
211        }
212        if (!first)
213        {
214            requestedURI += URLEncoder.encode(transmittedParameters.toString(), "UTF-8"); // request.getAttribute("requestedURI") is already encoded
215        }
216        
217        return requestedURI;
218    }
219    
220    private void _log(String message)
221    {
222        if (getLogger().isDebugEnabled())
223        {
224            getLogger().debug(message);
225        }
226    }
227    
228    private void _redirect(HttpResponse cmsResponse, Redirector redirector, String page, boolean permanent) throws Exception
229    {
230        String location = cmsResponse.getFirstHeader("Location").getValue();
231        
232        if (permanent && redirector instanceof PermanentRedirector)
233        {
234            ((PermanentRedirector) redirector).permanentRedirect(false, location);
235        }
236        else
237        {
238            redirector.redirect(false, location);
239        }
240        
241        _log("Redirect '" + page + "' to '" + location + "'");
242    }
243    
244    /**
245     * Copy some response headers from the back-office.
246     * @param request the front-office client request.
247     * @param response the front-office client response.
248     * @param cmsResponse the response from the back-office.
249     * @param names the header names.
250     */
251    protected void _copyHeaders(Request request, Response response, HttpResponse cmsResponse, String[] names)
252    {
253        for (String name : names)
254        {
255            Header[] headers = cmsResponse.getHeaders(name);
256            for (Header header : headers)
257            {
258                String value = header.getValue();
259                
260                if (name.startsWith("Set-Cookie") && value.startsWith("JSESSIONID="))
261                {
262                    if (getLogger().isWarnEnabled())
263                    {
264                        getLogger().warn("Receiving JSESSIONID cookie from the back-office.");
265                    }
266                    
267                    Matcher cookieMatcher = __COOKIE_PATH_PATTERN.matcher(value);
268                    if (cookieMatcher.matches())
269                    {
270                        String path = cookieMatcher.group(1);
271                        String newPath = request.getContextPath();
272                        if (StringUtils.isEmpty(newPath))
273                        {
274                            newPath = "/";
275                        }
276                        value = value.replace(path, "Path=" + newPath);
277                    }
278                    value = value.replace("JSESSIONID=", __BACKOFFICE_JSESSION_ID + "=");
279                    
280                }
281                
282                response.addHeader(name, value);
283            }
284        }
285    }
286    
287    /**
288     * Read the page from the back-office response and write it into the cache.
289     * @param decodedPage the page path.
290     * @param cmsResponse the back-office response, containing the page.
291     * @throws IOException if an error occurs writing the page into the cache.
292     */
293    protected void _writePageOnDisk(String decodedPage, HttpResponse cmsResponse) throws IOException
294    {
295        if (getLogger().isDebugEnabled())
296        {
297            getLogger().debug("The page is cacheable, writing into the cache: " + decodedPage);
298        }
299        
300        File root = SiteCacheHelper.getRootCache();
301        
302        File file = getFile(root, decodedPage);
303        if (!file.exists() && cmsResponse != null && cmsResponse.getEntity() != null)
304        {
305            file.getParentFile().mkdirs();
306            
307            try (FileOutputStream fos = new FileOutputStream(file);) 
308            {
309                cmsResponse.getEntity().writeTo(fos);
310            }
311            catch (IOException e)
312            {
313                throw new IOException("Error writing the file '" + file.getAbsolutePath() + "' into the cache, check that the directory is writable.", e);
314            }
315        }
316        
317        if (getLogger().isDebugEnabled())
318        {
319            getLogger().debug("Page " + decodedPage + " written into the cache.");
320        }
321    }
322    
323    /**
324     * Get the cache file to write for the corresponding page.
325     * @param root the cache root folder.
326     * @param pagePath the page path.
327     * @return a valid file.
328     */
329    protected File getFile(File root, String pagePath)
330    {
331        File file = new File(root, pagePath);
332        
333        if (!SiteCacheHelper.isValid(file))
334        {
335            String validPath = SiteCacheHelper.getHashedFilePath(pagePath);
336            
337            file = new File(root, validPath);
338        }
339        
340        return file;
341    }
342    
343}