/* This file is part of Cyclos (www.cyclos.org). A project of the Social Trade Organisation (www.socialtrade.org). Cyclos is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. Cyclos is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Cyclos; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package nl.strohalm.cyclos.struts; import java.io.IOException; import java.sql.Connection; import java.sql.SQLException; import java.util.HashSet; import java.util.Set; import javax.servlet.RequestDispatcher; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import nl.strohalm.cyclos.annotations.Inject; import nl.strohalm.cyclos.entities.exceptions.LockingException; import nl.strohalm.cyclos.entities.settings.events.LocalSettingsChangeListener; import nl.strohalm.cyclos.exceptions.ApplicationException; import nl.strohalm.cyclos.http.ResettableHttpServletRequest; import nl.strohalm.cyclos.http.ResettableHttpServletResponse; import nl.strohalm.cyclos.services.access.exceptions.SystemOfflineException; import nl.strohalm.cyclos.services.settings.SettingsService; import nl.strohalm.cyclos.utils.ActionHelper; import nl.strohalm.cyclos.utils.DataIteratorHelper; import nl.strohalm.cyclos.utils.ExceptionHelper; import nl.strohalm.cyclos.utils.RequestHelper; import nl.strohalm.cyclos.utils.SpringHelper; import nl.strohalm.cyclos.utils.access.LoggedUser; import nl.strohalm.cyclos.utils.logging.LoggingHandler; import nl.strohalm.cyclos.utils.logging.TraceLogDTO; import nl.strohalm.cyclos.utils.transaction.CurrentTransactionData; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.struts.action.Action; import org.apache.struts.action.ActionForm; import org.apache.struts.action.ActionForward; import org.apache.struts.action.ActionMapping; import org.apache.struts.action.ActionServlet; import org.apache.struts.action.SecureTilesRequestProcessor; import org.apache.struts.config.ForwardConfig; import org.apache.struts.config.ModuleConfig; import org.hibernate.FlushMode; import org.hibernate.Session; import org.hibernate.Transaction; import org.hibernate.connection.ConnectionProvider; import org.hibernate.engine.SessionFactoryImplementor; import org.springframework.orm.hibernate3.SessionHolder; import org.springframework.transaction.support.TransactionSynchronizationManager; /** * Custom struts request processor. Among other things, we control the DB transactions here, opening a read-write transaction for action execution * @author luis */ public class CyclosRequestProcessor extends SecureTilesRequestProcessor { public static class ExecutionResult { private boolean commit; private boolean errorWasSilenced; private boolean hasWrite; private boolean traceLog; private boolean longTransaction; private Throwable error; private ActionForward forward; public Throwable getError() { return error; } public ActionForward getForward() { return forward; } public boolean isCommit() { return commit; } public boolean isErrorWasSilenced() { return errorWasSilenced; } public boolean isHasWrite() { return hasWrite; } public boolean isLongTransaction() { return longTransaction; } public boolean isTraceLog() { return traceLog; } } public static final String EXECUTION_RESULT_KEY = "cyclos.executionResult"; public static final String NO_TRANSACTION_KEY = "cyclos.noTransactionManagement"; private static final Log LOG = LogFactory.getLog(CyclosRequestProcessor.class); private SettingsService settingsService; private LoggingHandler loggingHandler; private SessionFactoryImplementor sessionFactory; private ConnectionProvider connectionProvider; private ActionHelper actionHelper; public SettingsService getSettingsService() { return settingsService; } @Override public void init(final ActionServlet servlet, final ModuleConfig moduleConfig) throws ServletException { super.init(servlet, moduleConfig); SpringHelper.injectBeans(servlet.getServletContext(), this); final CyclosControllerConfig config = (CyclosControllerConfig) moduleConfig.getControllerConfig(); settingsService.addListener(config); config.initialize(settingsService.getLocalSettings()); } @Override public void process(final HttpServletRequest request, final HttpServletResponse response) throws IOException, ServletException { try { request.setAttribute(EXECUTION_RESULT_KEY, new ExecutionResult()); super.process(request, response); } catch (final Exception e) { if (e instanceof IOException) { throw (IOException) e; } else if (e instanceof ServletException) { throw (ServletException) e; } else { throw new RuntimeException(e); } } finally { cleanUpTransaction(request); } } @Inject public void setActionHelper(final ActionHelper actionHelper) { this.actionHelper = actionHelper; } @Inject public void setLoggingHandler(final LoggingHandler loggingHandler) { this.loggingHandler = loggingHandler; } @Inject public void setSessionFactory(final SessionFactoryImplementor sessionFactory) { this.sessionFactory = sessionFactory; connectionProvider = sessionFactory.getConnectionProvider(); } @Inject public void setSettingsService(final SettingsService settingsService) { this.settingsService = settingsService; } /** * Override action creation to inject spring beans */ @Override protected Action processActionCreate(final HttpServletRequest request, final HttpServletResponse response, final ActionMapping actionMapping) throws IOException { synchronized (actions) { Action action = (Action) actions.get(actionMapping.getType()); if (action != null) { return action; } else { action = super.processActionCreate(request, response, actionMapping); if (action == null) { return null; } // Register the action as listener if (action instanceof LocalSettingsChangeListener) { settingsService.addListener((LocalSettingsChangeListener) action); } // Inject the required beans try { SpringHelper.injectBeans(getServletContext(), action); } catch (final Exception e) { // we must remove the already added action instance (by super.processActionCreate(...)) actions.remove(actionMapping.getType()); LOG.error("Error injecting beans on " + action, e); throw new IllegalStateException(e); } return action; } } } /** * Override form creation to remove the form from session if the request was triggered by the menu */ @Override @SuppressWarnings("unchecked") protected ActionForm processActionForm(final HttpServletRequest request, final HttpServletResponse response, final ActionMapping actionMapping) { final HttpSession session = request.getSession(); if (StringUtils.isEmpty(actionMapping.getName())) { return null; } if (RequestHelper.isFromMenu(request)) { session.removeAttribute(actionMapping.getName()); } // Add form to session final ActionForm form = super.processActionForm(request, response, actionMapping); if ("session".equals(actionMapping.getScope())) { Set<String> sessionForms = (Set<String>) session.getAttribute("sessionForms"); if (sessionForms == null) { sessionForms = new HashSet<String>(); session.setAttribute("sessionForms", sessionForms); } sessionForms.add(actionMapping.getName()); } return form; } /** * Here is where the actual action will be invoked. Before it, we open a read-write DB transaction. */ @Override protected ActionForward processActionPerform(final HttpServletRequest request, final HttpServletResponse response, final Action action, final ActionForm form, final ActionMapping mapping) throws IOException, ServletException { // Clean previous session stored forms if (RequestHelper.isFromMenu(request)) { cleanSessionForms(request, form); } // The main processing happens inside a loop, because we need to retry the main execution after a locking exception / deadlock final ResettableHttpServletRequest resetableRequest = new ResettableHttpServletRequest(request); final ResettableHttpServletResponse resetableResponse = new ResettableHttpServletResponse(response); while (true) { try { final ExecutionResult result = executeAction(resetableRequest, resetableResponse, action, form, mapping); // Apply the state resetableRequest.applyState(); resetableResponse.applyState(); return result.forward; } catch (final LockingException e) { // Retry the transaction, resetting the state resetableRequest.resetState(); resetableResponse.resetState(); logDebug(request, "Locking error - re-executing action"); } catch (final SystemOfflineException e) { return ActionHelper.sendError(mapping, request, response, "error.systemOffline"); } } } /** * If the given {@link ForwardConfig} will actually include something (probably a JSP), open a new read-only transaction, so lazy-loading and data * iteration will work */ @Override protected void processForwardConfig(final HttpServletRequest request, final HttpServletResponse response, final ForwardConfig forward) throws IOException, ServletException { final ExecutionResult result = (ExecutionResult) request.getAttribute(EXECUTION_RESULT_KEY); final boolean isInclude = forward != null && !forward.getRedirect(); final boolean needsReadOnlyConnection = isInclude && !result.longTransaction; if (needsReadOnlyConnection) { // When needed, open a new read-only connection for the include openReadOnlyConnection(request); } // The top-most invocation will manage transaction. Any includes won't final boolean managesTransaction = !noTransaction(request); if (managesTransaction) { request.setAttribute(NO_TRANSACTION_KEY, true); } try { super.processForwardConfig(request, response, forward); } catch (final IllegalStateException e) { LOG.warn("Error processing the forward to " + forward.getPath()); } finally { if (managesTransaction) { // Remove the attribute, otherwise, the top-most invocations of commitOrRollbackTransaction() or rollbackTransaction() will do nothing request.removeAttribute(NO_TRANSACTION_KEY); } if (result.longTransaction) { // Close the long running read-write connection result.commit = false; commitOrRollbackTransaction(request); } else if (needsReadOnlyConnection) { rollbackReadOnlyConnection(request); } } } @Override protected HttpServletRequest processMultipart(final HttpServletRequest request) { final HttpServletRequest multipartRequest = super.processMultipart(request); if (multipartRequest != request) { request.setAttribute("multipartRequest", multipartRequest); } return multipartRequest; } @Override protected void processPopulate(final HttpServletRequest request, final HttpServletResponse response, final ActionForm form, final ActionMapping mapping) throws ServletException { try { super.processPopulate(request, response, form, mapping); } catch (final Exception e) { LOG.error("Error populating " + form + " in " + mapping.getPath(), e); request.getSession().setAttribute("errorKey", "error.validation"); final RequestDispatcher rd = request.getRequestDispatcher("/do/error"); try { rd.forward(request, response); } catch (final IOException e1) { LOG.error("Error while trying to forward to error page", e1); } } } @SuppressWarnings("unchecked") private void cleanSessionForms(final HttpServletRequest request, final ActionForm form) { final HttpSession session = request.getSession(); final Set<String> sessionForms = (Set<String>) session.getAttribute("sessionForms"); if (sessionForms != null) { for (final String name : sessionForms) { final ActionForm currentForm = (ActionForm) session.getAttribute(name); if (currentForm != form) { session.removeAttribute(name); } } } } private void cleanUpTransaction(final HttpServletRequest request) { if (noTransaction(request)) { return; } logDebug(request, "Cleaning up transaction"); // Close any open iterators DataIteratorHelper.closeOpenIterators(); // Close the session final SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(sessionFactory); if (holder != null) { try { final Session session = holder.getSession(); if (session.isOpen()) { session.close(); } } catch (final Exception e) { LOG.error("Error closing Hibernate session", e); } TransactionSynchronizationManager.unbindResourceIfPossible(sessionFactory); } // Close the connection final Connection connection = (Connection) TransactionSynchronizationManager.getResource(connectionProvider); if (connection != null) { try { connectionProvider.closeConnection(connection); } catch (final Exception e) { LOG.error("Error closing database connection", e); } TransactionSynchronizationManager.unbindResourceIfPossible(connectionProvider); } // Cleanup the Spring transaction data TransactionSynchronizationManager.setCurrentTransactionReadOnly(false); TransactionSynchronizationManager.setActualTransactionActive(false); // Cleanup the current transaction data CurrentTransactionData.cleanup(); request.removeAttribute(EXECUTION_RESULT_KEY); } private void commitOrRollbackTransaction(final HttpServletRequest request) throws IOException, ServletException { if (noTransaction(request)) { return; } final ExecutionResult result = (ExecutionResult) request.getAttribute(EXECUTION_RESULT_KEY); final SessionHolder sessionHolder = getSessionHolder(); // Commit or rollback the transaction boolean runCommitListeners = false; boolean lockingException = false; if (result.commit) { logDebug(request, "Committing transaction"); runCommitListeners = true; // Marked as commit - should run commit listeners try { sessionHolder.getTransaction().commit(); } catch (final Throwable t) { // In case of locking exceptions, we must make sure the correct exception type is returned, so the transaction will be retried lockingException = ExceptionHelper.isLockingException(t); result.error = t; } } else { if (result.error == null && !result.hasWrite) { // Transaction was semantically a commit, so, the commit listeners should run. // However, as there where no writes the transaction will be rolled back runCommitListeners = true; logDebug(request, "Nothing written to database. Rolling-back transaction"); } else { logDebug(request, "Rolling-back transaction"); } sessionHolder.getTransaction().rollback(); } // Disconnect the session sessionHolder.getSession().disconnect(); if (lockingException) { // There was a locking exception - throw it now, so the transaction will be retried cleanUpTransaction(request); throw new LockingException(); } // Unbind the session holder, so that listeners which should open a new transaction on this same thread won't be messed up TransactionSynchronizationManager.unbindResourceIfPossible(sessionFactory); // Run the transaction listener CurrentTransactionData.detachListeners().runListeners(runCommitListeners); // Bind the session holder again TransactionSynchronizationManager.bindResource(sessionFactory, sessionHolder); // Log the execution if a regular user is logged in and this is not an AJAX request if (result.traceLog) { traceLog(request, result.error, result.commit); } // The resulting error was not silenced (i.e, by the BaseAction's try / catch. Log and rethrow if (result.error != null && !result.errorWasSilenced) { actionHelper.generateLog(request, servlet.getServletContext(), result.error); ActionHelper.throwException(result.error); } } private ExecutionResult doExecuteAction(final HttpServletRequest request, final HttpServletResponse response, final Action action, final ActionForm form, final ActionMapping mapping) { final ExecutionResult result = (ExecutionResult) request.getAttribute(EXECUTION_RESULT_KEY); try { result.forward = super.processActionPerform(request, response, action, form, mapping); // Get data from CurrentTransactionData result.error = CurrentTransactionData.getError(); result.errorWasSilenced = result.error != null; result.longTransaction = DataIteratorHelper.hasOpenIteratorsRequiringOpenConnection(); if (result.error == null) { final SessionHolder holder = getSessionHolder(); holder.getSession().flush(); } result.hasWrite = CurrentTransactionData.hasWrite(); if (result.error instanceof ApplicationException) { // When there's an ApplicationError, we can still commit, depending on the flag result.commit = result.hasWrite && !((ApplicationException) result.error).isShouldRollback(); } else { // The general case: commit if there are no errors result.commit = result.hasWrite && !result.errorWasSilenced; } } catch (final ApplicationException e) { result.commit = e.isShouldRollback() ? false : true; result.error = e; } catch (final Throwable e) { result.commit = false; result.error = e; } result.traceLog = generateTraceLog(request); return result; } private ExecutionResult executeAction(final HttpServletRequest request, final HttpServletResponse response, final Action action, final ActionForm form, final ActionMapping mapping) throws IOException, ServletException { // Open a new read-write connection try { openReadWriteConnection(request); } catch (final Exception e) { throw new SystemOfflineException(); } // Execute the actual action final ExecutionResult result = doExecuteAction(request, response, action, form, mapping); // Commit or rollback transaction if the current request does not need a long transaction if (!result.longTransaction) { commitOrRollbackTransaction(request); } else { logDebug(request, "Keeping connection open because there are open iterators"); } return result; } private boolean generateTraceLog(final HttpServletRequest request) { final String uri = request.getRequestURI(); return LoggedUser.getAccessType() == LoggedUser.AccessType.USER && !RequestHelper.isAjax(request) && !uri.endsWith("/login") && !uri.endsWith("/logout"); } private SessionHolder getSessionHolder() { return (SessionHolder) TransactionSynchronizationManager.getResource(sessionFactory); } private void logDebug(final HttpServletRequest request, final String message) { if (LOG.isDebugEnabled()) { final String method = RequestHelper.isValidation(request) ? "VALIDATION" : request.getMethod(); LOG.debug(String.format("%s (%s): %s", request.getRequestURI(), method, message)); } } private boolean noTransaction(final HttpServletRequest request) { return Boolean.TRUE.equals(request.getAttribute(NO_TRANSACTION_KEY)); } private void openReadOnlyConnection(final HttpServletRequest request) { if (noTransaction(request)) { return; } logDebug(request, "Opening read-only transaction for include"); final Connection connection = (Connection) TransactionSynchronizationManager.getResource(connectionProvider); final SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(sessionFactory); final Session session = holder.getSession(); session.setFlushMode(FlushMode.MANUAL); session.setDefaultReadOnly(true); session.reconnect(connection); TransactionSynchronizationManager.setCurrentTransactionReadOnly(true); } private void openReadWriteConnection(final HttpServletRequest request) throws IOException, ServletException { if (noTransaction(request)) { return; } logDebug(request, "Opening a new read-write transaction"); // Open a read-write transaction Connection connection = null; Session session = null; SessionHolder holder = null; Transaction transaction = null; try { connection = connectionProvider.getConnection(); TransactionSynchronizationManager.bindResource(connectionProvider, connection); session = sessionFactory.openSession(connection); holder = new SessionHolder(session); transaction = session.beginTransaction(); holder.setTransaction(transaction); TransactionSynchronizationManager.bindResource(sessionFactory, holder); holder.setSynchronizedWithTransaction(true); TransactionSynchronizationManager.setActualTransactionActive(true); TransactionSynchronizationManager.setCurrentTransactionReadOnly(false); } catch (final Exception e) { if (connection != null) { try { connectionProvider.closeConnection(connection); } catch (final SQLException e1) { LOG.warn("Error closing connection", e1); } finally { TransactionSynchronizationManager.unbindResourceIfPossible(connectionProvider); TransactionSynchronizationManager.unbindResourceIfPossible(sessionFactory); } } LOG.error("Couldn't open a transaction", e); ActionHelper.throwException(e); } } private void rollbackReadOnlyConnection(final HttpServletRequest request) { if (noTransaction(request)) { return; } final Connection connection = (Connection) TransactionSynchronizationManager.getResource(connectionProvider); try { logDebug(request, "Rolling back read-only transaction"); connection.rollback(); } catch (final SQLException e) { throw new IllegalStateException(e); } } private void traceLog(final HttpServletRequest request, final Throwable error, final boolean hasWrite) { final HttpServletRequest multipartRequest = (HttpServletRequest) request.getAttribute("multipartRequest"); final HttpServletRequest req = multipartRequest == null ? request : multipartRequest; final TraceLogDTO params = new TraceLogDTO(); params.setUser(LoggedUser.user()); params.setRemoteAddress(req.getRemoteAddr()); params.setRequestMethod(req.getMethod()); params.setPath(req.getRequestURI()); params.setParameters(ActionHelper.getParameterMap(req)); final HttpSession session = req.getSession(false); params.setSessionId(session == null ? null : session.getId()); params.setError(error); params.setHasDatabaseWrites(hasWrite); loggingHandler.trace(params); } }