001package com.randomnoun.common.webapp;
002
003/* (c) 2013-15 randomnoun. All Rights Reserved. This work is licensed under a
004 * BSD Simplified License. (http://www.randomnoun.com/bsd-simplified.html)
005 */
006
007import java.beans.PropertyVetoException;
008import java.io.BufferedInputStream;
009import java.io.File;
010import java.io.FileInputStream;
011import java.io.InputStream;
012import java.io.InputStreamReader;
013import java.sql.Connection;
014import java.sql.DriverManager;
015import java.sql.SQLException;
016import java.util.Enumeration;
017import java.util.HashMap;
018import java.util.Iterator;
019import java.util.Map;
020import java.util.Properties;
021import java.util.ResourceBundle;
022
023import javax.naming.Context;
024import javax.naming.InitialContext;
025import javax.naming.NamingException;
026import javax.sql.DataSource;
027
028import com.mchange.v2.c3p0.ComboPooledDataSource;
029
030import org.apache.log4j.Logger;
031import org.apache.log4j.PropertyConfigurator;
032import org.springframework.jdbc.core.JdbcTemplate;
033import org.springframework.jdbc.datasource.SingleConnectionDataSource;
034import org.springframework.dao.DataAccessResourceFailureException;
035
036import com.randomnoun.common.PropertyParser;
037import com.randomnoun.common.Struct;
038import com.randomnoun.common.Text;
039import com.randomnoun.common.jexl.sql.SqlGenerator;
040import com.randomnoun.common.security.SecurityAuthenticator;
041import com.randomnoun.common.security.SecurityContext;
042import com.randomnoun.common.security.SecurityLoader;
043import com.randomnoun.common.security.User;
044import com.randomnoun.common.security.impl.NullSecurityAuthenticatorImpl;
045import com.randomnoun.common.security.impl.NullSecurityLoaderImpl;
046import com.randomnoun.common.security.impl.SpringSecurityAuthenticatorImpl;
047import com.randomnoun.common.security.impl.SpringSecurityLoaderImpl;
048
049
050/** An attempt to standardise config objects across the handful of webapps in this
051 * workspace.  
052 * 
053 * <p>Holds configuration data for this web application. Is used to look up 
054 * resources for use by other application components, including:
055 * 
056 * <ul>
057 * <li>database connections (JdbcTemplates)
058 * <li>security contexts (for authentication / authorisation)
059 * </ul>
060 * 
061 * @author Greg
062 * 
063 */
064public abstract class AppConfigBase extends Properties {
065
066        /** Generated serialVersionUID */
067        private static final long serialVersionUID = -5375559262217993785L;
068
069    /** Logger instance for this class */
070    public static Logger logger = Logger.getLogger(AppConfigBase.class);
071    
072    /** Global instance */
073    public static AppConfigBase instance = null;
074
075    /** JdbcTemplates for this AppConfig */
076    protected Map<String, JdbcTemplate> jdbcTemplates = new HashMap<String, JdbcTemplate>();
077    
078    /** Datasource underlying the jdbcTemplates */
079    protected Map<String, DataSource> dataSources = new HashMap<String, DataSource>();
080    
081        /** Security context used for authentication and authorisation */
082    protected SecurityContext securityContext;
083    
084        /** If set to non-null, indicates an exception that occurred during initialisation. */
085        protected Throwable initialisationFailure = null;
086    
087        /** The name of the -D directive which, if present, specifies the folder containing the
088         * configuration file; e.g. "com.randomnoun.appName.configPath"
089         */
090        public abstract String getSystemPropertyKeyConfigPath();
091
092        /** The name of the file which configures this application; e.g. "/appName-web.properties" */
093        public abstract String getConfigResourceLocation();
094        
095    /** Ensure that the database drivers are accessible, for simple and dbcp connection types 
096     * 
097     * @throws RuntimeExcption if the database drivers are not on the classpath of this webapp
098     */
099        protected void initDatabase() {
100        logger.info("Initialising database...");
101        try {
102                String connectionType = getProperty("database.connectionType");
103                if (connectionType==null || 
104                  connectionType.equals("simple") || 
105                  connectionType.equals("dbcp")) { 
106                        Class.forName(getProperty("database.driver")).getDeclaredConstructor().newInstance();
107                }
108        } catch (Exception e) {
109            initialisationFailure = e;
110            throw new RuntimeException("Could not load database driver '" + getProperty("database.driver") + "'", e);
111        }
112    }
113    
114    /** Initialises log4j 
115     */
116    protected void initLogger() {
117        System.out.println("Initialising log4j...");
118        
119        Properties props = new Properties();
120        props.putAll(PropertyParser.restrict(this, "log4j", false));
121    
122        // replace ${xxxx}-style placeholders
123        String key, value, varName, varValue;
124        int startIdx, endIdx;
125        for (Enumeration<Object> e = props.keys(); e.hasMoreElements(); ) {
126                key = (String) e.nextElement();
127                value = (String) props.get(key);
128                startIdx = 0;
129                startIdx = value.indexOf("${", startIdx);
130                while (startIdx > -1) {
131                        endIdx = value.indexOf("}", startIdx+2);
132                        if (endIdx>-1) {
133                                varName = value.substring(startIdx+2, endIdx-startIdx-2);
134                                varValue = (String) this.get(varName);
135                                if (varValue == null) { varValue = ""; }
136                                value = value.substring(0, startIdx) + varValue + value.substring(endIdx + 1);
137                                startIdx = value.indexOf("${", startIdx); 
138                        } else {
139                                startIdx = -1;
140                        }
141                }
142        }
143        
144        PropertyConfigurator.configure(props);
145        logger.debug("log4j initialised");
146    }
147    
148    protected void initSecurityContext() {
149        
150        if (securityContext==null) {
151                        logger.info("Initialising security context...");
152                        if (!"false".equals(getProperty("auth.enableSecurityContext"))) {
153                                Map<String, Object> securityProperties  = new HashMap<String, Object>();
154                                // no customerIds, applicationIds, auditUsernames or table suffixes ! Yay !
155                                String dbVendor = (String) this.get("database.vendor");
156                                if (Text.isBlank(dbVendor)) { dbVendor = SqlGenerator.DATABASE_MYSQL; }
157                                
158                                securityProperties.put(SpringSecurityLoaderImpl.INIT_DATABASE_VENDOR, dbVendor);
159                                securityProperties.put(SpringSecurityLoaderImpl.INIT_JDBCTEMPLATE, getJdbcTemplate());
160                                
161                                securityProperties.put(SecurityContext.INIT_CASE_INSENSITIVE, this.get("securityContext.caseInsensitive"));
162                                securityProperties.put(SecurityContext.INIT_USER_CACHE_SIZE, this.get("securityContext.userCacheSize"));
163                                securityProperties.put(SecurityContext.INIT_USER_CACHE_EXPIRY, this.get("securityContext.userCacheExpiry"));
164                                
165                                SecurityLoader securityLoader = new SpringSecurityLoaderImpl();
166                                SecurityAuthenticator securityAuthenticator = new SpringSecurityAuthenticatorImpl();
167                                
168                                securityContext = new SecurityContext(securityProperties, securityLoader, securityAuthenticator);
169                        } else {
170                                Map<String, Object> securityProperties  = new HashMap<String, Object>();
171                                SecurityLoader securityLoader = new NullSecurityLoaderImpl();
172                                SecurityAuthenticator securityAuthenticator = new NullSecurityAuthenticatorImpl();
173                                securityContext = new SecurityContext(securityProperties, securityLoader, securityAuthenticator);
174                        }
175        }
176    }
177    
178    /** Sets the hostname application property. Should be called before {@link #loadProperties()},
179     * which may have ENVIRONMENT settings that rely on the hostname.
180     */
181    protected void initHostname() {
182        String hostname;
183        try {
184            java.net.InetAddress localMachine = java.net.InetAddress.getLocalHost();
185            hostname = localMachine.getHostName();
186        } catch(java.net.UnknownHostException uhe) {
187            hostname = "localhost";
188        }
189        this.put("hostname", hostname);
190    }
191
192    /** Returns a bundle to be used for internationalisation. The bundle will initially be
193     * searched for using the standard ResourceBundle.getBundle(name) method (i.e. in the
194     * server's classpath); and, if this fails, will return 
195     * ResourceBundle("resources.i18n." + name); which will search the i18n tree of the
196     * eomail project. 
197     * 
198     * @param i18nBundleName The bundle name to search for (e.g. "user")
199     * @param user The user for whom we are retrieving i18ned messages for (the locale of this
200     *   user will be used to determine which resource bundle to use)
201     * @return The ResourceBundle requested.
202     */    
203    public ResourceBundle getBundle(String i18nBundleName, User user) {
204        if (i18nBundleName.startsWith("resources")) {
205            return ResourceBundle.getBundle(i18nBundleName, user.getLocale());    
206        } else {
207            return ResourceBundle.getBundle("resources.i18n." + i18nBundleName, user.getLocale());
208        }
209        
210    }
211
212    
213    /** Load the configuration from both the classpath and the
214     * filesystem. Properties defined on the filesystem will override any contained within
215     * the web application. 
216     * 
217     * <p>Multiple environments can be specified in properties files using the 
218     * STARTENVIRONMENT/ENDENVIRONMENT tags. The tags use the machine's hostname as an
219     * environment specifier. 
220     */
221    protected void loadProperties() {
222        String configPath = System.getProperty(getSystemPropertyKeyConfigPath());
223        if (configPath==null) { configPath = "."; }
224        File configFile = new File(configPath + getConfigResourceLocation());
225        try {
226            // load from resources
227            System.out.println("Loading properties file for environment '" + this.getProperty("hostname") + "'");
228            InputStream is = this.getClass().getClassLoader().getResourceAsStream(getConfigResourceLocation());
229            if (is==null && getConfigResourceLocation().charAt(0)=='/') {
230                // command-line support
231                is = this.getClass().getClassLoader().getResourceAsStream(getConfigResourceLocation().substring(1));
232            }
233            if (is==null) {
234                System.out.println("Could not find internal property file");
235            } else {
236                PropertyParser parser = new PropertyParser(new InputStreamReader(is), this.getProperty("hostname"));
237                Properties props = parser.parse();
238                is.close();
239                this.putAll(props);
240            }
241            
242            // then load from file (if it exists)
243            if (configFile.exists()) {
244                System.out.println("Loading properties file from '" + configFile.getCanonicalPath() + "'");
245                is = new BufferedInputStream(new FileInputStream(configFile));
246                PropertyParser parser = new PropertyParser(new InputStreamReader(is), this.getProperty("hostname"));
247                Properties props = parser.parse();
248                is.close();
249                this.putAll(props);
250            } else {
251                System.out.println("Properties file not found at '" + configFile.getCanonicalPath() + "' ... using defaults");
252                
253            }
254        } catch (Exception e) {
255            this.initialisationFailure = e;
256            throw new RuntimeException("Could not load " + getConfigResourceLocation(), e);
257        }
258    }
259    
260    
261    /** Return a jdbcTemplate object that can be used to query the database 
262     * 
263     * @return a jdbcTemplate to the database
264     */
265    public JdbcTemplate getJdbcTemplate() {
266        return getJdbcTemplate(null);
267    }
268    
269    /** Returns a named jdbcTemplate object that can be used to query the database
270     *  
271     * @param connectionName The name of the connection
272     * 
273     * @return a jdbcTemplate to the database
274     */
275    public synchronized JdbcTemplate getJdbcTemplate(String connectionName) {
276        String prefix = connectionName == null ? "database." : "databases." + connectionName + ".";
277        
278        JdbcTemplate jt = jdbcTemplates.get(connectionName);
279        if (jt==null) {
280                String connectionType = getProperty(prefix + "connectionType");
281                if (connectionType==null) { connectionType = "simple"; }
282                
283                if (connectionType.equals("none")) {
284                        if (!jdbcTemplates.containsKey(connectionName)) {
285                                dataSources.put(connectionName, null);
286                                jdbcTemplates.put(connectionName, null);
287                        }
288                } else {
289                        String driver = getProperty(prefix + "driver");
290                        String url = getProperty(prefix + "url");
291                        String username = getProperty(prefix + "username");
292                        String password = getProperty(prefix + "password");
293                        logger.info("Retrieving " + connectionType + " connection for '" + username + "'/'" + password + "' @ '" + url + "'");
294                        DataSource ds = null;
295                        if (!Text.isBlank(driver)) { 
296                                try {
297                                        Class.forName(driver); 
298                                } catch (ClassNotFoundException e) {
299                                                logger.error("Error loading class '" + driver + "' for database '" + connectionName + "'", e);
300                                        } 
301                        }
302                        if (connectionType.equals("simple")) {
303                                if (url==null) { throw new NullPointerException(prefix + "url has not been initialised"); }
304                                Connection connection;
305                                try {
306                                    if (Text.isBlank(username)) {
307                                        connection = DriverManager.getConnection (url);
308                                    } else {
309                                        connection = DriverManager.getConnection (url, username, password);
310                                    }
311                                } catch (SQLException sqle) {
312                                    throw new DataAccessResourceFailureException("Could not open connection to database", sqle); 
313                                }
314                                ds = new SingleConnectionDataSource(connection, false);
315                                
316                        } else if (connectionType.equals("dbcp")) {
317                                throw new UnsupportedOperationException("dbcp is no longer supported; use c3p0 instead");
318                                
319                        } else if (connectionType.equals("c3p0")) {
320                                if (url==null) { throw new NullPointerException(prefix + "url has not been initialised"); }
321                                ComboPooledDataSource  cpds = new ComboPooledDataSource ();
322                                try {
323                                                cpds.setDriverClass(driver);
324                                        } catch (PropertyVetoException e) {
325                                                throw new IllegalStateException("Could not set driverClass '" + driver + "' for c3p0 datasource", e);
326                                        }
327                                @SuppressWarnings("unchecked")
328                                        Map<String,String> c3poProps = (Map<String, String>) PropertyParser.restrict(this, prefix + "c3p0", true);
329                                // remove extensions from props
330                                for (Iterator<Map.Entry<String,String>> i = c3poProps.entrySet().iterator(); i.hasNext(); ) { 
331                                        Map.Entry<String,String> e=i.next(); 
332                                        if (e.getKey().startsWith("extensions.")) { i.remove(); }
333                                }
334                                Struct.setFromMap(cpds, c3poProps, false, true, false);
335                                
336                                // set extensions separately
337                                @SuppressWarnings("unchecked")
338                                        Map<String,String> c3poExtensionProps = (Map<String, String>) PropertyParser.restrict(this, prefix + "c3p0.extensions", true);
339                                cpds.setExtensions(c3poExtensionProps);
340                                cpds.setJdbcUrl(url);
341                                if (!Text.isBlank(username)) {
342                                        cpds.setUser(username);
343                                        cpds.setPassword(password);
344                                }
345                                ds = cpds;
346                                
347                        } else if (connectionType.equals("jndi")) {
348                                String jndiName = getProperty(prefix + "jndiName");
349                                        try {
350                                                InitialContext ctx = new InitialContext();
351                                                Context envContext  = (Context) ctx.lookup("java:/comp/env");
352                                                ds = (DataSource) envContext.lookup(jndiName);
353                                                // could fallback to global datasource if comp/env is not here
354                                        } catch (NamingException e) {
355                                                throw new IllegalStateException("Could not retrieve datasource '" + jndiName + "' from JNDI", e);
356                                        }
357                        } else {
358                                throw new IllegalStateException("Unknown " + prefix + "connectionType property '" + connectionType + "')");
359                        }
360                        
361                        jt = new JdbcTemplate(ds);
362                        dataSources.put(connectionName, ds);
363                        jdbcTemplates.put(connectionName, jt);
364                }
365        }
366        return jt;
367    }
368    
369    
370        /** Return the security context for this application
371         * 
372         * @return the security context for this application
373         */
374    public SecurityContext getSecurityContext() {
375        if (securityContext==null) {
376                throw new IllegalStateException("Security context not initialised");
377        }
378        return securityContext;
379    }
380    
381
382        /** Determines whether a user 'may have' a permission on the application. 
383         * See the hasPermission method in SecurityContext for more details.
384         *
385         * @param user The user we are checking permissions for. 
386         * @param permission The permission we are checking.
387         * @return 'true' if the user may be authorised to perform the permission,
388         *   false otherwise
389         */
390        public boolean hasPermission(User user, String permission)
391        {
392                if ("false".equals(getProperty("auth.enableSecurityContext"))) { return true; }
393                SecurityContext securityContext = getSecurityContext();
394                return securityContext.hasPermission(user, permission);
395        }
396
397        /** Determines whether a user 'may have' a permission on the application. 
398         * See the hasPermission method in SecurityContext for more details. If the user
399         * does not have the permission, then a SecurityException is thrown
400         *
401         * @see SecurityContext#hasPermission(com.randomnoun.common.security.User, String)
402         *
403         * @param user The user we are checking permissions for. 
404         * @param permission The permission we are checking.
405         * 
406         * @throws SecurityException if the user does not have the supplied permission 
407         */
408        public void checkPermission(User user, String permission) throws SecurityException {
409                if (!hasPermission(user,  permission)) {
410                        throw new SecurityException("User does not have '" + permission + "' permission");
411                }
412    }
413
414        /** Determines whether a user has a permission on the application. 
415         * See the hasPermission method in SecurityContext for more details.
416         *
417         * @param user The user we are checking permissions for.
418         * @param permission The permission we are checking.
419         * @param resourceContext Fine-grained resource context
420         * @return 'true' if the user is authorised to perform the permission,
421         *   false otherwise
422         */
423        public boolean hasPermission(User user, String permission, Map<String, Object> resourceContext)
424        {
425                if ("false".equals(getProperty("auth.enableSecurityContext"))) { return true; }
426                SecurityContext securityContext = getSecurityContext();
427                return securityContext.hasPermission(user, permission, resourceContext);
428        }
429
430
431        /** Provides direct access to the datasource for this application. Most applications should use the 
432         * {@link #getJdbcTemplate()} method instead.
433         * 
434         * @return the dataSource underlying the jdbcTemplate to the database
435         */
436        public DataSource getDataSource() {
437                getJdbcTemplate(); // make sure default dataSource is initialised
438                return dataSources.get(null);
439        }
440
441        /** Provides direct access to a named datasource for this application. Most applications should use the 
442         * {@link #getJdbcTemplate(String)} method instead.
443         * 
444         * @param connectionName the name of the connection
445         * 
446         * @return the dataSource underlying the named jdbcTemplate to the database 
447         */
448        public DataSource getDataSource(String connectionName) {
449                getJdbcTemplate(connectionName); // make sure default dataSource is initialised
450                return dataSources.get(connectionName);
451        }
452
453}