001/*
002 * (C) Copyright 2006-2018 Nuxeo SA (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     Thierry Delprat
018 *     Florent Guillaume
019 */
020package org.nuxeo.ecm.platform.web.common.requestcontroller.filter;
021
022import static java.time.ZonedDateTime.ofInstant;
023import static java.time.format.DateTimeFormatter.ofPattern;
024
025import java.io.IOException;
026import java.security.Principal;
027import java.time.Instant;
028import java.time.ZoneId;
029import java.time.ZonedDateTime;
030import java.time.format.DateTimeFormatter;
031import java.util.Locale;
032import java.util.Map.Entry;
033import java.util.concurrent.TimeUnit;
034import java.util.concurrent.locks.Lock;
035import java.util.concurrent.locks.ReentrantLock;
036
037import javax.servlet.Filter;
038import javax.servlet.FilterChain;
039import javax.servlet.FilterConfig;
040import javax.servlet.ServletException;
041import javax.servlet.ServletRequest;
042import javax.servlet.ServletResponse;
043import javax.servlet.http.HttpServletRequest;
044import javax.servlet.http.HttpServletResponse;
045import javax.servlet.http.HttpSession;
046
047import org.apache.commons.logging.Log;
048import org.apache.commons.logging.LogFactory;
049import org.nuxeo.ecm.core.api.NuxeoException;
050import org.nuxeo.ecm.core.io.download.DownloadHelper;
051import org.nuxeo.ecm.platform.web.common.ServletHelper;
052import org.nuxeo.ecm.platform.web.common.requestcontroller.service.RequestControllerManager;
053import org.nuxeo.ecm.platform.web.common.requestcontroller.service.RequestFilterConfig;
054import org.nuxeo.runtime.api.Framework;
055import org.nuxeo.runtime.transaction.TransactionHelper;
056import org.nuxeo.runtime.transaction.TransactionRuntimeException;
057
058/**
059 * Filter to handle transactions, response buffering, and request synchronization.
060 *
061 * @author tiry
062 */
063public class NuxeoRequestControllerFilter implements Filter {
064
065    private static final Log log = LogFactory.getLog(NuxeoRequestControllerFilter.class);
066
067    protected static final String SESSION_LOCK_KEY = "NuxeoSessionLockKey";
068
069    protected static final String SYNCED_REQUEST_FLAG = "NuxeoSessionAlreadySync";
070
071    protected static final int LOCK_TIMEOUT_S = 120;
072
073    // formatted http Expires: Thu, 01 Dec 1994 16:00:00 GMT
074    public static final DateTimeFormatter HTTP_EXPIRES_DATE_FORMAT = ofPattern("EEE, dd MMM yyyy HH:mm:ss z").withZone(
075            ZoneId.of("GMT")).withLocale(Locale.US);
076
077    @Override
078    public void init(FilterConfig filterConfig) {
079        // nothing to do
080    }
081
082    @Override
083    public void destroy() {
084        // nothing to do
085    }
086
087    public static String doFormatLogMessage(HttpServletRequest request, String info) {
088        String remoteHost = RemoteHostGuessExtractor.getRemoteHost(request);
089        Principal principal = request.getUserPrincipal();
090        String principalName = principal != null ? principal.getName() : "none";
091        String uri = request.getRequestURI();
092        HttpSession session = request.getSession(false);
093        String sessionId = session != null ? session.getId() : "none";
094        String threadName = Thread.currentThread().getName();
095        return "remote=" + remoteHost + ",principal=" + principalName + ",uri=" + uri + ",session=" + sessionId
096                + ",thread=" + threadName + ",info=" + info;
097    }
098
099    @Override
100    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
101            throws IOException, ServletException {
102        HttpServletRequest request = (HttpServletRequest) servletRequest;
103        HttpServletResponse response = (HttpServletResponse) servletResponse;
104        if (log.isDebugEnabled()) {
105            log.debug(doFormatLogMessage(request, "Entering NuxeoRequestController filter"));
106        }
107
108        RequestControllerManager rcm = Framework.getService(RequestControllerManager.class);
109        RequestFilterConfig config = rcm.getConfigForRequest(request);
110        boolean useSync = config.needSynchronization();
111        boolean useTx = config.needTransaction();
112        boolean useBuffer = config.needTransactionBuffered();
113        if (log.isDebugEnabled()) {
114            log.debug(doFormatLogMessage(request,
115                    "Handling request with tx=" + useTx + " and sync=" + useSync + " and buffer=" + useBuffer));
116        }
117        addHeaders(request, response, config);
118
119        boolean sessionSynched = false;
120        boolean txStarted = false;
121        boolean buffered = false;
122        try {
123            ServletHelper.setServletContext(request.getServletContext());
124            if (useSync) {
125                sessionSynched = simpleSyncOnSession(request);
126            }
127            if (useTx) {
128                if (!TransactionHelper.isTransactionActiveOrMarkedRollback()) {
129                    txStarted = ServletHelper.startTransaction(request);
130                    if (!txStarted) {
131                        throw new ServletException("Failed to start transaction");
132                    }
133                }
134                if (useBuffer) {
135                    response = new BufferingHttpServletResponse(response);
136                    buffered = true;
137                }
138            }
139            chain.doFilter(request, response);
140        } catch (IOException | ServletException | RuntimeException e) {
141            // Don't call response.sendError, because it commits the response
142            // which prevents NuxeoExceptionFilter from returning a custom error page.
143            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
144            if (TransactionHelper.isTransactionActive()) {
145                TransactionHelper.setTransactionRollbackOnly();
146            }
147            if (DownloadHelper.isClientAbortError(e)) {
148                DownloadHelper.logClientAbort(e);
149            } else if (e instanceof RuntimeException) { // NOSONAR
150                throw new ServletException(e);
151            } else {
152                throw e; // IOException | ServletException
153            }
154        } finally {
155            try {
156                if (txStarted) {
157                    try {
158                        TransactionHelper.commitOrRollbackTransaction();
159                    } catch (TransactionRuntimeException e) {
160                        // commit failed, report this to the client before stopping buffering
161                        // Don't call response.sendError, because it commits the response
162                        // which prevents NuxeoExceptionFilter from returning a custom error page.
163                        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
164                        log.error(e, e); // don't rethrow inside finally
165                    }
166                }
167            } finally {
168                if (buffered) {
169                    ((BufferingHttpServletResponse) response).stopBuffering();
170                }
171                if (sessionSynched) {
172                    simpleReleaseSyncOnSession(request);
173                }
174                ServletHelper.removeServletContext();
175            }
176        }
177
178        if (log.isDebugEnabled()) {
179            log.debug(doFormatLogMessage(request, "Exiting NuxeoRequestController filter"));
180        }
181    }
182
183    /**
184     * Synchronizes the HttpSession.
185     * <p>
186     * Uses a {@link Lock} object in the HttpSession and locks it. If HttpSession is not created, exits without locking
187     * anything.
188     */
189    public static boolean simpleSyncOnSession(HttpServletRequest request) {
190        HttpSession session = request.getSession(false);
191        if (session == null) {
192            if (log.isDebugEnabled()) {
193                log.debug(doFormatLogMessage(request, "HttpSession does not exist, this request won't be synched"));
194            }
195            return false;
196        }
197
198        if (log.isDebugEnabled()) {
199            log.debug(doFormatLogMessage(request, "Trying to sync on session "));
200        }
201
202        if (request.getAttribute(SYNCED_REQUEST_FLAG) != null) {
203            if (log.isWarnEnabled()) {
204                log.warn(doFormatLogMessage(request,
205                        "Request has already be synced, filter is reentrant, exiting without locking"));
206            }
207            return false;
208        }
209
210        Lock lock = (Lock) session.getAttribute(SESSION_LOCK_KEY);
211        if (lock == null) {
212            lock = new ReentrantLock();
213            session.setAttribute(SESSION_LOCK_KEY, lock);
214        }
215
216        boolean locked = false;
217        try {
218            locked = lock.tryLock(LOCK_TIMEOUT_S, TimeUnit.SECONDS);
219        } catch (InterruptedException e) {
220            Thread.currentThread().interrupt();
221            throw new NuxeoException(e);
222        }
223
224        if (locked) {
225            request.setAttribute(SYNCED_REQUEST_FLAG, true);
226            if (log.isDebugEnabled()) {
227                log.debug(doFormatLogMessage(request, "Request synced on session"));
228            }
229        } else {
230            if (log.isDebugEnabled()) {
231                log.debug(doFormatLogMessage(request, "Sync timeout"));
232            }
233        }
234
235        return locked;
236    }
237
238    /**
239     * Releases the {@link Lock} if present in the HttpSession.
240     */
241    public static boolean simpleReleaseSyncOnSession(HttpServletRequest request) {
242        HttpSession session = request.getSession(false);
243        if (session == null) {
244            if (log.isDebugEnabled()) {
245                log.debug(doFormatLogMessage(request,
246                        "No more HttpSession: can not unlock !, HttpSession must have been invalidated"));
247            }
248            return false;
249        }
250        log.debug("Trying to unlock on session " + session.getId() + " on Thread " + Thread.currentThread().getId());
251
252        Lock lock = (Lock) session.getAttribute(SESSION_LOCK_KEY);
253        if (lock == null) {
254            log.error("Unable to find session lock, HttpSession may have been invalidated");
255            return false;
256        } else {
257            lock.unlock();
258            if (request.getAttribute(SYNCED_REQUEST_FLAG) != null) {
259                request.removeAttribute(SYNCED_REQUEST_FLAG);
260            }
261            if (log.isDebugEnabled()) {
262                log.debug("session unlocked on Thread ");
263            }
264            return true;
265        }
266    }
267
268    protected void addHeaders(HttpServletRequest request, HttpServletResponse response,
269            RequestFilterConfig config) {
270        addConfiguredHeaders(response);
271        if (request.getMethod().equals("GET")) {
272            addCacheHeaders(response, config);
273        }
274    }
275
276    protected void addConfiguredHeaders(HttpServletResponse response) {
277        RequestControllerManager rcm = Framework.getService(RequestControllerManager.class);
278        for (Entry<String, String> en : rcm.getResponseHeaders().entrySet()) {
279            String headerName = en.getKey();
280            if (!response.containsHeader(headerName)) {
281                response.addHeader(headerName, en.getValue());
282            }
283        }
284    }
285
286    protected void addCacheHeaders(HttpServletResponse response, RequestFilterConfig config) {
287        if (config.isCached()) {
288            String privateOrPublic = config.isPrivate() ? "private" : "public";
289            response.setHeader("Cache-Control", privateOrPublic + ", max-age=" + config.getCacheTime());
290            long expires = System.currentTimeMillis() + Long.parseLong(config.getCacheTime()) * 1000;
291            ZonedDateTime zdt = ofInstant(Instant.ofEpochMilli(expires), ZoneId.systemDefault());
292            response.setHeader("Expires", HTTP_EXPIRES_DATE_FORMAT.format(zdt));
293        } else if (config.isPrivate()) {
294            response.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
295        }
296    }
297
298}