/** * This Source Code Form is subject to the terms of the Mozilla Public License, * v. 2.0. If a copy of the MPL was not distributed with this file, You can * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. * * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS * graphic logo is a trademark of OpenMRS Inc. */ package org.openmrs.web.filter.update; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang.StringUtils; import org.apache.log4j.Appender; import org.apache.log4j.Logger; import org.openmrs.util.DatabaseUpdateException; import org.openmrs.util.DatabaseUpdater; import org.openmrs.util.DatabaseUpdater.ChangeSetExecutorCallback; import org.openmrs.util.InputRequiredException; import org.openmrs.util.MemoryAppender; import org.openmrs.util.OpenmrsConstants; import org.openmrs.util.OpenmrsUtil; import org.openmrs.util.RoleConstants; import org.openmrs.util.Security; import org.openmrs.web.Listener; import org.openmrs.web.WebDaemon; import org.openmrs.web.filter.StartupFilter; import org.openmrs.web.filter.initialization.InitializationFilter; import org.openmrs.web.filter.util.CustomResourceLoader; import org.openmrs.web.filter.util.ErrorMessageConstants; import org.openmrs.web.filter.util.FilterUtil; import org.slf4j.LoggerFactory; import org.springframework.web.context.ContextLoader; import liquibase.changelog.ChangeSet; import liquibase.exception.LockException; /** * This is the second filter that is processed. It is only active when OpenMRS has some liquibase * updates that need to be run. If updates are needed, this filter/wizard asks for a super user to * authenticate and review the updates before continuing. */ public class UpdateFilter extends StartupFilter { protected final org.slf4j.Logger log = LoggerFactory.getLogger(getClass()); /** * The velocity macro page to redirect to if an error occurs or on initial startup */ private static final String DEFAULT_PAGE = "maintenance.vm"; /** * The page that lists off all the currently unexecuted changes */ private static final String REVIEW_CHANGES = "reviewchanges.vm"; private static final String PROGRESS_VM_AJAXREQUEST = "updateProgress.vm.ajaxRequest"; /** * The model object behind this set of screens */ private UpdateFilterModel model = null; /** * Variable set as soon as the update is done or verified to not be needed so that future calls * through this filter are a simple boolean check */ private static boolean updatesRequired = true; /** * Used on all pages after the first to make sure the user isn't trying to cheat and do some url * magic to hack in. */ private boolean authenticatedSuccessfully = false; private UpdateFilterCompletion updateJob; /** * Variable set to true as soon as the update begins and set to false when the process ends. * This thread should only be accesses through the synchronized method. */ private static boolean isDatabaseUpdateInProgress = false; /** * Variable set to true when the db lock is released. It's needed to prevent repeatedly * releasing this lock by other threads. This var should only be accessed through * the synchronized method. */ private static Boolean lockReleased = false; /** * Called by {@link #doFilter(ServletRequest, ServletResponse, FilterChain)} on GET requests * * @param httpRequest * @param httpResponse */ @Override protected void doGet(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException, ServletException { Map<String, Object> referenceMap = new HashMap<>(); checkLocaleAttributesForFirstTime(httpRequest); // we need to save current user language in references map since it will be used when template // will be rendered if (httpRequest.getSession().getAttribute(FilterUtil.LOCALE_ATTRIBUTE) != null) { referenceMap .put(FilterUtil.LOCALE_ATTRIBUTE, httpRequest.getSession().getAttribute(FilterUtil.LOCALE_ATTRIBUTE)); } // do step one of the wizard renderTemplate(DEFAULT_PAGE, referenceMap, httpResponse); } /** * Called by {@link #doFilter(ServletRequest, ServletResponse, FilterChain)} on POST requests * * @see org.openmrs.web.filter.StartupFilter#doPost(javax.servlet.http.HttpServletRequest, * javax.servlet.http.HttpServletResponse) */ @Override protected synchronized void doPost(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException, ServletException { final String updJobStatus = "updateJobStarted"; String page = httpRequest.getParameter("page"); Map<String, Object> referenceMap = new HashMap<>(); if (httpRequest.getSession().getAttribute(FilterUtil.LOCALE_ATTRIBUTE) != null) { referenceMap .put(FilterUtil.LOCALE_ATTRIBUTE, httpRequest.getSession().getAttribute(FilterUtil.LOCALE_ATTRIBUTE)); } // step one if (DEFAULT_PAGE.equals(page)) { String username = httpRequest.getParameter("username"); String password = httpRequest.getParameter("password"); log.debug("Attempting to authenticate user: " + username); if (authenticateAsSuperUser(username, password)) { log.debug("Authentication successful. Redirecting to 'reviewupdates' page."); // set a variable so we know that the user started here authenticatedSuccessfully = true; //Set variable to tell us whether updates are already in progress referenceMap.put("isDatabaseUpdateInProgress", isDatabaseUpdateInProgress); // if another super user has already launched database update // allow current super user to review update progress if (isDatabaseUpdateInProgress) { referenceMap.put(updJobStatus, true); httpResponse.setContentType("text/html"); renderTemplate(REVIEW_CHANGES, referenceMap, httpResponse); return; } // we will only get here if the db update is NOT running. // so if we find a db lock, we should release it because // it was leftover from a previous db update crash if (!isLockReleased() && DatabaseUpdater.isLocked()) { // first we trying to release db lock if it exists try { DatabaseUpdater.releaseDatabaseLock(); setLockReleased(true); } catch (LockException e) { // do nothing } // if lock was released successfully we need to get unrun changes model.updateChanges(); } // need to configure velocity tool box for using user's preferred locale // so we should store it for further using when configuring velocity tool context String localeParameter = FilterUtil.restoreLocale(username); httpRequest.getSession().setAttribute(FilterUtil.LOCALE_ATTRIBUTE, localeParameter); referenceMap.put(FilterUtil.LOCALE_ATTRIBUTE, localeParameter); renderTemplate(REVIEW_CHANGES, referenceMap, httpResponse); } else { // if not authenticated, show main page again try { log.debug("Sleeping for 3 seconds because of a bad username/password"); Thread.sleep(3000); } catch (InterruptedException e) { log.error("Unable to sleep", e); throw new ServletException("Got interrupted while trying to sleep thread", e); } errors.put(ErrorMessageConstants.UPDATE_ERROR_UNABLE_AUTHENTICATE, null); renderTemplate(DEFAULT_PAGE, referenceMap, httpResponse); } } // step two of wizard in case if there were some warnings else if (REVIEW_CHANGES.equals(page)) { if (!authenticatedSuccessfully) { // throw the user back to the main page because they are cheating renderTemplate(DEFAULT_PAGE, referenceMap, httpResponse); return; } //if no one has run any required updates if (!isDatabaseUpdateInProgress) { isDatabaseUpdateInProgress = true; updateJob = new UpdateFilterCompletion(); updateJob.start(); // allows current user see progress of running update // and also will hide the "Run Updates" button referenceMap.put(updJobStatus, true); } else { referenceMap.put("isDatabaseUpdateInProgress", true); // as well we need to allow current user to // see progress of already started updates // and also will hide the "Run Updates" button referenceMap.put(updJobStatus, true); } renderTemplate(REVIEW_CHANGES, referenceMap, httpResponse); } else if (PROGRESS_VM_AJAXREQUEST.equals(page)) { httpResponse.setContentType("text/json"); httpResponse.setHeader("Cache-Control", "no-cache"); Map<String, Object> result = new HashMap<>(); if (updateJob != null) { result.put("hasErrors", updateJob.hasErrors()); if (updateJob.hasErrors()) { errors.putAll(updateJob.getErrors()); } if (updateJob.hasWarnings() && updateJob.getExecutingChangesetId() == null) { result.put("hasWarnings", updateJob.hasWarnings()); StringBuilder sb = new StringBuilder("<ul>"); for (String warning : updateJob.getUpdateWarnings()) { sb.append("<li>" + warning + "</li>"); } sb.append("</ul>"); result.put("updateWarnings", sb.toString()); result.put("updateLogFile", StringUtils.replace(OpenmrsUtil.getApplicationDataDirectory() + DatabaseUpdater.DATABASE_UPDATES_LOG_FILE, "\\", "\\\\")); updateJob.hasUpdateWarnings = false; updateJob.getUpdateWarnings().clear(); } result.put("updatesRequired", updatesRequired()); result.put("message", updateJob.getMessage()); result.put("changesetIds", updateJob.getChangesetIds()); result.put("executingChangesetId", updateJob.getExecutingChangesetId()); Appender appender = Logger.getRootLogger().getAppender("MEMORY_APPENDER"); if (appender instanceof MemoryAppender) { MemoryAppender memoryAppender = (MemoryAppender) appender; List<String> logLines = memoryAppender.getLogLines(); // truncate the list to the last five so we don't overwhelm jquery if (logLines.size() > 5) { logLines = logLines.subList(logLines.size() - 5, logLines.size()); } result.put("logLines", logLines); } else { result.put("logLines", new ArrayList<String>()); } } String jsonText = toJSONString(result); httpResponse.getWriter().write(jsonText); } } /** * It sets locale attribute for current session when user is making first GET http request * to application. It retrieves user locale from request object and checks if this locale is * supported by application. If not, it tries to load system default locale. If it's not specified it * uses {@link Locale#ENGLISH} by default * * @param httpRequest the http request object */ public void checkLocaleAttributesForFirstTime(HttpServletRequest httpRequest) { Locale locale = httpRequest.getLocale(); String systemDefaultLocale = FilterUtil.readSystemDefaultLocale(null); if (CustomResourceLoader.getInstance(httpRequest).getAvailablelocales().contains(locale)) { httpRequest.getSession().setAttribute(FilterUtil.LOCALE_ATTRIBUTE, locale.toString()); log.info("Used client's locale " + locale.toString()); } else if (StringUtils.isNotBlank(systemDefaultLocale)) { httpRequest.getSession().setAttribute(FilterUtil.LOCALE_ATTRIBUTE, systemDefaultLocale); log.info("Used system default locale " + systemDefaultLocale); } else { httpRequest.getSession().setAttribute(FilterUtil.LOCALE_ATTRIBUTE, Locale.ENGLISH.toString()); log.info("Used default locale " + Locale.ENGLISH.toString()); } } /** * Look in the users table for a user with this username and password and see if they have a * role of {@link OpenmrsConstants#SUPERUSER_ROLE}. * * @param usernameOrSystemId user entered username * @param password user entered password * @return true if this user has the super user role * @see #isSuperUser(Connection, Integer) * @should return false if given invalid credentials * @should return false if given user is not superuser * @should return true if given user is superuser * @should not authorize retired superusers * @should authenticate with systemId */ protected boolean authenticateAsSuperUser(String usernameOrSystemId, String password) throws ServletException { Connection connection = null; try { connection = DatabaseUpdater.getConnection(); String select = "select user_id, password, salt from users where (username = ? or system_id = ?) and retired = '0'"; PreparedStatement statement = null; try { statement = connection.prepareStatement(select); statement.setString(1, usernameOrSystemId); statement.setString(2, usernameOrSystemId); if (statement.execute()) { ResultSet results = null; try { results = statement.getResultSet(); if (results.next()) { Integer userId = results.getInt(1); DatabaseUpdater.setAuthenticatedUserId(userId); String storedPassword = results.getString(2); String salt = results.getString(3); String passwordToHash = password + salt; boolean result = Security.hashMatches(storedPassword, passwordToHash) && isSuperUser(connection, userId); return result; } } finally { if (results != null) { try { results.close(); } catch (Exception resultsCloseEx) { log.error("Failed to quietly close ResultSet", resultsCloseEx); } } } } } finally { if (statement != null) { try { statement.close(); } catch (Exception statementCloseEx) { log.error("Failed to quietly close Statement", statementCloseEx); } } } } catch (Exception connectionEx) { log .error( "Error while trying to authenticate as super user. Ignore this if you are upgrading from OpenMRS 1.5 to 1.6", connectionEx); // we may not have upgraded User to have retired instead of voided yet, so if the query above fails, we try // again the old way if (connection != null) { String select = "select user_id, password, salt from users where (username = ? or system_id = ?) and voided = '0'"; PreparedStatement statement = null; try { statement = connection.prepareStatement(select); statement.setString(1, usernameOrSystemId); statement.setString(2, usernameOrSystemId); if (statement.execute()) { ResultSet results = null; try { results = statement.getResultSet(); if (results.next()) { Integer userId = results.getInt(1); DatabaseUpdater.setAuthenticatedUserId(userId); String storedPassword = results.getString(2); String salt = results.getString(3); String passwordToHash = password + salt; boolean result = Security.hashMatches(storedPassword, passwordToHash) && isSuperUser(connection, userId); return result; } } finally { if (results != null) { try { results.close(); } catch (Exception resultsCloseEx) { log.error("Failed to quietly close ResultSet", resultsCloseEx); } } } } } catch (Exception unhandeledEx) { log.error("Error while trying to authenticate as super user (voided version)", unhandeledEx); } finally { if (statement != null) { try { statement.close(); } catch (Exception statementCloseEx) { log.error("Failed to quietly close Statement", statementCloseEx); } } } } } finally { if (connection != null) { try { connection.close(); } catch (SQLException e) { log.debug("Error while closing the database", e); } } } return false; } /** * Checks the given user to see if they have been given the * {@link OpenmrsConstants#SUPERUSER_ROLE} role. This method does not look at child roles. * * @param connection the java sql connection to use * @param userId the user id to look at * @return true if the given user is a super user * @throws SQLException * @should return true if given user has superuser role * @should return false if given user does not have the super user role */ protected boolean isSuperUser(Connection connection, Integer userId) throws SQLException { // the 'Administrator' part of this string is necessary because if the database was upgraded // by OpenMRS 1.6 alpha then System Developer was renamed to that. This has to be here so we // can roll back that change in 1.6 beta+ String select = "select 1 from user_role where user_id = ? and (role = ? or role = 'Administrator')"; PreparedStatement statement = connection.prepareStatement(select); statement.setInt(1, userId); statement.setString(2, RoleConstants.SUPERUSER); if (statement.execute()) { ResultSet results = statement.getResultSet(); if (results.next()) { return results.getInt(1) == 1; } } return false; } /**`` * Do everything to get openmrs going. * * @param servletContext the servletContext from the filterconfig * @see Listener#startOpenmrs(ServletContext) */ private void startOpenmrs(ServletContext servletContext) throws Exception { // start spring // after this point, all errors need to also call: contextLoader.closeWebApplicationContext(event.getServletContext()) // logic copied from org.springframework.web.context.ContextLoaderListener ContextLoader contextLoader = new ContextLoader(); contextLoader.initWebApplicationContext(servletContext); try { WebDaemon.startOpenmrs(servletContext); } catch (Exception exception) { contextLoader.closeWebApplicationContext(servletContext); throw exception; } } /** * @see javax.servlet.Filter#init(javax.servlet.FilterConfig) */ @Override public void init(FilterConfig filterConfig) throws ServletException { super.init(filterConfig); log.debug("Initializing the UpdateFilter"); if (!InitializationFilter.initializationRequired() || (Listener.isSetupNeeded() && Listener.runtimePropertiesFound())) { model = new UpdateFilterModel(); /* * In this case, Listener#runtimePropertiesFound == true and InitializationFilter Wizard is skipped, * so no need to reset Context's RuntimeProperties again, because of Listener.contextInitialized has set it. */ try { // this pings the DatabaseUpdater.updatesRequired which also // considers a db lock to be a 'required update' if (model.updateRequired) { setUpdatesRequired(true); } else if (model.changes == null) { setUpdatesRequired(false); } else { if (log.isDebugEnabled()) { log.debug("Setting updates required to " + (!model.changes.isEmpty()) + " because of the size of unrun changes"); } setUpdatesRequired(!model.changes.isEmpty()); } } catch (Exception e) { throw new ServletException("Unable to determine if updates are required", e); } } else { /* * The initialization wizard will update the database to the latest version, so the user will not need any updates here. * See end of InitializationFilter#InitializationCompletion */ log .debug("Setting updates required to false because the user doesn't have any runtime properties yet or database is empty"); setUpdatesRequired(false); } } /** * @see org.openmrs.web.filter.StartupFilter#getModel() */ @Override protected Object getModel() { // this object was initialized in the #init(FilterConfig) method return model; } /** * @see org.openmrs.web.filter.StartupFilter#skipFilter(HttpServletRequest) */ @Override public boolean skipFilter(HttpServletRequest httpRequest) { return !PROGRESS_VM_AJAXREQUEST.equals(httpRequest.getParameter("page")) && !updatesRequired(); } /** * Used by the Listener to know if this filter wants to do its magic * * @return true if updates have been determined to be required * @see #init(FilterConfig) * @see Listener#setupNeeded */ public static synchronized boolean updatesRequired() { return updatesRequired; } /** * @param updatesRequired the updatesRequired to set */ public static synchronized void setUpdatesRequired(boolean updatesRequired) { UpdateFilter.updatesRequired = updatesRequired; } /** * Indicates if database lock was released. It will also used to prevent releasing * existing lock of liquibasechangeloglock table by another user, when he also tries * to run database update when another user is currently running it */ public static Boolean isLockReleased() { return lockReleased; } public static synchronized void setLockReleased(Boolean lockReleased) { UpdateFilter.lockReleased = lockReleased; } /** * @see org.openmrs.web.filter.StartupFilter#getTemplatePrefix() */ @Override protected String getTemplatePrefix() { return "org/openmrs/web/filter/update/"; } /** * This class controls the final steps and is used by the ajax calls to know what updates have * been executed. TODO: Break this out into a separate (non-inner) class */ private class UpdateFilterCompletion { private Thread thread; private String executingChangesetId = null; private List<String> changesetIds = new ArrayList<>(); private Map<String, Object[]> errors = new HashMap<>(); private String message = null; private boolean erroneous = false; private boolean hasUpdateWarnings = false; private List<String> updateWarnings = new LinkedList<>(); public synchronized void reportError(String error, Object... params) { Map<String, Object[]> errors = new HashMap<>(); errors.put(error, params); reportErrors(errors); } public synchronized void reportErrors(Map<String, Object[]> errs) { errors.putAll(errs); erroneous = true; } public synchronized boolean hasErrors() { return erroneous; } public synchronized Map<String, Object[]> getErrors() { return errors; } /** * Start the completion stage. This fires up the thread to do all the work. */ public void start() { setUpdatesRequired(true); thread.start(); } public synchronized void setMessage(String message) { this.message = message; } public synchronized String getMessage() { return message; } public synchronized void addChangesetId(String changesetid) { this.changesetIds.add(changesetid); this.executingChangesetId = changesetid; } public synchronized List<String> getChangesetIds() { return changesetIds; } public synchronized String getExecutingChangesetId() { return executingChangesetId; } /** * @return the database updater Warnings */ public synchronized List<String> getUpdateWarnings() { return updateWarnings; } public synchronized boolean hasWarnings() { return hasUpdateWarnings; } public synchronized void reportWarnings(List<String> warnings) { updateWarnings.addAll(warnings); hasUpdateWarnings = true; } /** * This class does all the work of creating the desired database, user, updates, etc */ public UpdateFilterCompletion() { Runnable r = new Runnable() { /** * TODO split this up into multiple testable methods * * @see java.lang.Runnable#run() */ @Override public void run() { try { /** * A callback class that prints out info about liquibase changesets */ class PrintingChangeSetExecutorCallback implements ChangeSetExecutorCallback { private String message; public PrintingChangeSetExecutorCallback(String message) { this.message = message; } /** * @see org.openmrs.util.DatabaseUpdater.ChangeSetExecutorCallback#executing(liquibase.ChangeSet, * int) */ @Override public void executing(ChangeSet changeSet, int numChangeSetsToRun) { addChangesetId(changeSet.getId()); setMessage(message); } } try { setMessage("Updating the database to the latest version"); List<String> warnings = DatabaseUpdater.executeChangelog(null, null, new PrintingChangeSetExecutorCallback("Updating database tables to latest version ")); executingChangesetId = null; // clear out the last changeset if (CollectionUtils.isNotEmpty(warnings)) { reportWarnings(warnings); warnings = null; } } catch (InputRequiredException inputRequired) { // the user would be stepped through the questions returned here. log.error("Not implemented", inputRequired); model.updateChanges(); reportError(ErrorMessageConstants.UPDATE_ERROR_INPUT_NOT_IMPLEMENTED, inputRequired.getMessage()); return; } catch (DatabaseUpdateException e) { log.error("Unable to update the database", e); Map<String, Object[]> errors = new HashMap<>(); errors.put(ErrorMessageConstants.UPDATE_ERROR_UNABLE, null); for (String message : Arrays.asList(e.getMessage().split("\n"))) { errors.put(message, null); } model.updateChanges(); reportErrors(errors); return; } setMessage("Starting OpenMRS"); try { startOpenmrs(filterConfig.getServletContext()); } catch (Exception e) { log.error("Unable to complete the startup.", e); reportError(ErrorMessageConstants.UPDATE_ERROR_COMPLETE_STARTUP, e.getMessage()); return; } // set this so that the wizard isn't run again on next page load setUpdatesRequired(false); } finally { if (!hasErrors()) { setUpdatesRequired(false); } //reset to let other user's make requests after updates are run isDatabaseUpdateInProgress = false; } } }; thread = new Thread(r); } } }