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                                // builtin props
328                                @SuppressWarnings("unchecked")
329                                        Map<String,String> c3poProps = (Map<String, String>) PropertyParser.restrict(this, prefix + "c3p0", true);
330                                // remove extensions and non-builtins from props
331                                for (Iterator<Map.Entry<String,String>> i = c3poProps.entrySet().iterator(); i.hasNext(); ) { 
332                                        Map.Entry<String,String> e=i.next(); 
333                                        if (e.getKey().startsWith("extensions.")) { i.remove(); }
334                                        if (e.getKey().startsWith("props.")) { i.remove(); }
335                                }
336                                Struct.setFromMap(cpds, c3poProps, false, true, false);
337                                
338                                // set extensions separately
339                                @SuppressWarnings("unchecked")
340                                        Map<String,String> c3poExtensionProps = (Map<String, String>) PropertyParser.restrict(this, prefix + "c3p0.extensions", true);
341                                cpds.setExtensions(c3poExtensionProps);
342                                
343                                @SuppressWarnings("unchecked")
344                                Map<String,String> c3poDatasourceProps = (Map<String, String>) PropertyParser.restrict(this, prefix + "c3p0.props", true);
345                                Properties p = new Properties();
346                                p.putAll(c3poDatasourceProps);
347                                cpds.setProperties(p);
348                                
349                                cpds.setJdbcUrl(url);
350                                if (!Text.isBlank(username)) {
351                                        cpds.setUser(username);
352                                        cpds.setPassword(password);
353                                }
354                                ds = cpds;
355                                
356                        } else if (connectionType.equals("jndi")) {
357                                String jndiName = getProperty(prefix + "jndiName");
358                                        try {
359                                                InitialContext ctx = new InitialContext();
360                                                Context envContext  = (Context) ctx.lookup("java:/comp/env");
361                                                ds = (DataSource) envContext.lookup(jndiName);
362                                                // could fallback to global datasource if comp/env is not here
363                                        } catch (NamingException e) {
364                                                throw new IllegalStateException("Could not retrieve datasource '" + jndiName + "' from JNDI", e);
365                                        }
366                        } else {
367                                throw new IllegalStateException("Unknown " + prefix + "connectionType property '" + connectionType + "')");
368                        }
369                        
370                        jt = new JdbcTemplate(ds);
371                        dataSources.put(connectionName, ds);
372                        jdbcTemplates.put(connectionName, jt);
373                }
374        }
375        return jt;
376    }
377    
378    
379        /** Return the security context for this application
380         * 
381         * @return the security context for this application
382         */
383    public SecurityContext getSecurityContext() {
384        if (securityContext==null) {
385                throw new IllegalStateException("Security context not initialised");
386        }
387        return securityContext;
388    }
389    
390
391        /** Determines whether a user 'may have' a permission on the application. 
392         * See the hasPermission method in SecurityContext for more details.
393         *
394         * @param user The user we are checking permissions for. 
395         * @param permission The permission we are checking.
396         * @return 'true' if the user may be authorised to perform the permission,
397         *   false otherwise
398         */
399        public boolean hasPermission(User user, String permission)
400        {
401                if ("false".equals(getProperty("auth.enableSecurityContext"))) { return true; }
402                SecurityContext securityContext = getSecurityContext();
403                return securityContext.hasPermission(user, permission);
404        }
405
406        /** Determines whether a user 'may have' a permission on the application. 
407         * See the hasPermission method in SecurityContext for more details. If the user
408         * does not have the permission, then a SecurityException is thrown
409         *
410         * @see SecurityContext#hasPermission(com.randomnoun.common.security.User, String)
411         *
412         * @param user The user we are checking permissions for. 
413         * @param permission The permission we are checking.
414         * 
415         * @throws SecurityException if the user does not have the supplied permission 
416         */
417        public void checkPermission(User user, String permission) throws SecurityException {
418                if (!hasPermission(user,  permission)) {
419                        throw new SecurityException("User does not have '" + permission + "' permission");
420                }
421    }
422
423        /** Determines whether a user has a permission on the application. 
424         * See the hasPermission method in SecurityContext for more details.
425         *
426         * @param user The user we are checking permissions for.
427         * @param permission The permission we are checking.
428         * @param resourceContext Fine-grained resource context
429         * @return 'true' if the user is authorised to perform the permission,
430         *   false otherwise
431         */
432        public boolean hasPermission(User user, String permission, Map<String, Object> resourceContext)
433        {
434                if ("false".equals(getProperty("auth.enableSecurityContext"))) { return true; }
435                SecurityContext securityContext = getSecurityContext();
436                return securityContext.hasPermission(user, permission, resourceContext);
437        }
438
439
440        /** Provides direct access to the datasource for this application. Most applications should use the 
441         * {@link #getJdbcTemplate()} method instead.
442         * 
443         * @return the dataSource underlying the jdbcTemplate to the database
444         */
445        public DataSource getDataSource() {
446                getJdbcTemplate(); // make sure default dataSource is initialised
447                return dataSources.get(null);
448        }
449
450        /** Provides direct access to a named datasource for this application. Most applications should use the 
451         * {@link #getJdbcTemplate(String)} method instead.
452         * 
453         * @param connectionName the name of the connection
454         * 
455         * @return the dataSource underlying the named jdbcTemplate to the database 
456         */
457        public DataSource getDataSource(String connectionName) {
458                getJdbcTemplate(connectionName); // make sure default dataSource is initialised
459                return dataSources.get(connectionName);
460        }
461
462}