Yi EungJun 2015-03-03
config: Fix bugs about handling config files
Yobi did not handle some cases correctly about config files.

* Fix a bug that the config files specified by user are ignored; In the
  case, do not create the files by default even if they don't exist.
* Do not update application.secret if the config file is out of conf
  directory. The file may not be Yobi's.
* Tell the user to update application.secret if Yobi failed to update
  it.
* Refactoring: Move members for config files into the ConfigFile inner
  class
@7caf00dfa63a8188c1f6756194e42bc14504ab41
app/Global.java
--- app/Global.java
+++ app/Global.java
@@ -19,35 +19,40 @@
  * limitations under the License.
  */
 
-import mailbox.MailboxService;
 import com.avaje.ebean.Ebean;
+import com.typesafe.config.ConfigFactory;
 import controllers.SvnApp;
 import controllers.UserApp;
 import controllers.routes;
+import mailbox.MailboxService;
 import models.*;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.impl.cookie.DateUtils;
 import play.Application;
+import play.Configuration;
 import play.GlobalSettings;
 import play.Play;
 import play.api.mvc.Handler;
 import play.data.Form;
+import play.libs.F.Promise;
 import play.mvc.Action;
 import play.mvc.Http;
 import play.mvc.Http.RequestHeader;
-import play.mvc.Result;
-import play.libs.F.Promise;
-
 import play.mvc.Result;
 import play.mvc.Results;
 import utils.*;
 import views.html.welcome.restart;
 import views.html.welcome.secret;
 
+import javax.annotation.Nonnull;
+import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 import java.lang.reflect.Method;
 import java.math.BigInteger;
 import java.net.InetAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -60,41 +65,70 @@
 
 public class Global extends GlobalSettings {
     private static final String[] INITIAL_ENTITY_NAME = {"users", "roles", "siteAdmins"};
-    public static final String APPLICATION_CONF_DEFAULT = "application.conf.default";
-    public static final String APPLICATION_CONF = "application.conf";
-    public static final String CONFIG_DIRNAME = "conf";
     private final String DEFAULT_SECRET = "VA2v:_I=h9>?FYOH:@ZhW]01P<mWZAKlQ>kk>Bo`mdCiA>pDw64FcBuZdDh<47Ew";
 
     private boolean isSecretInvalid = false;
     private boolean isRestartRequired = false;
-    private boolean isValidationRequired = false;
     private MailboxService mailboxService = new MailboxService();
+    private boolean hasFailedToUpdateSecretKey = false;
+
+    private ConfigFile configFile = new ConfigFile("config", "application.conf");
+    private ConfigFile loggerConfigFile = new ConfigFile("logger", "application-logger.xml");
 
     @Override
     public Configuration onLoadConfig(play.Configuration config, File path, ClassLoader classloader) {
-        String basePath = path.getAbsolutePath();
-        Path pathToDefaultConfig = Paths.get(basePath, CONFIG_DIRNAME, APPLICATION_CONF_DEFAULT);
-        Path pathToConfig = Paths.get(basePath, CONFIG_DIRNAME, APPLICATION_CONF);
-        File configFile = pathToConfig.toFile();
+        initLoggerConfig();
+        return initConfig(classloader);
+    }
 
-        if (!configFile.exists()) {
-            try {
-                Files.copy(pathToDefaultConfig, pathToConfig);
-            } catch (IOException e) {
-                play.Logger.error("Failed to initialize configuration", e);
-                return null;
-            }
-            Config parsedConfig = ConfigFactory.parseFileAnySyntax(configFile);
-            return new Configuration(ConfigFactory.load(classloader, parsedConfig));
-        } else {
-            if (!configFile.isFile()) {
-                play.Logger.error(
-                        "Failed to initialize configuration: " + pathToConfig + " is a directory.");
-                return null;
-            }
+    /**
+     * Creates application.xml by default if necessary
+     *
+     * @param   classloader
+     * @return  the configuration read from the created file,
+     *          or null if this method didn't create the file.
+     */
+    private Configuration initConfig(ClassLoader classloader) {
+        if (configFile.isLocationSpecified()) {
+            return null;
         }
 
-        return null;
+        try {
+            if (configFile.getPath().toFile().exists()) {
+                return null;
+            }
+        } catch (URISyntaxException e) {
+            play.Logger.error("Failed to check whether the config file exists", e);
+            return null;
+        }
+
+        try {
+            configFile.createByDefault();
+            return new Configuration(ConfigFactory.load(classloader,
+                    ConfigFactory.parseFileAnySyntax(configFile.getPath().toFile())));
+        } catch (Exception e) {
+            play.Logger.error("Failed to initialize configuration", e);
+            return null;
+        }
+    }
+
+    /**
+     * Creates application-logger.xml by default if necessary
+     *
+     * Note: This method creates application-logger.xml even if logger.xml exists.
+     */
+    private void initLoggerConfig() {
+        try {
+            if (!loggerConfigFile.isLocationSpecified() && !loggerConfigFile.getPath().toFile().exists()) {
+                try {
+                    loggerConfigFile.createByDefault();
+                } catch (Exception e) {
+                    play.Logger.error("Failed to initialize logger configuration", e);
+                }
+            }
+        } catch (URISyntaxException e) {
+            play.Logger.error("Failed to check whether the logger config file exists", e);
+        }
     }
 
     @Override
@@ -159,7 +193,7 @@
         return new Action.Simple() {
             @Override
             public Promise<Result> call(Http.Context ctx) throws Throwable {
-                return Promise.pure((Result) ok(restart.render()));
+                return Promise.pure((Result) ok(restart.render(hasFailedToUpdateSecretKey)));
             }
         };
     }
@@ -176,9 +210,14 @@
                     }
 
                     User siteAdmin = SiteAdmin.updateDefaultSiteAdmin(newSiteAdminUserForm.get());
-                    replaceSiteSecretKey(createSeed(siteAdmin.loginId + ":" + siteAdmin.password));
+                    try {
+                        updateSiteSecretKey(createSeed(siteAdmin.loginId + ":" + siteAdmin.password));
+                    } catch (Exception e) {
+                        play.Logger.warn("Failed to update secret key", e);
+                        hasFailedToUpdateSecretKey = true;
+                    }
                     isRestartRequired = true;
-                    return Promise.pure((Result) ok(restart.render()));
+                    return Promise.pure((Result) ok(restart.render(hasFailedToUpdateSecretKey)));
                 } else {
                     return Promise.pure((Result) ok(secret.render(SiteAdmin.SITEADMIN_DEFAULT_LOGINID, new Form<>(User.class))));
                 }
@@ -194,15 +233,18 @@
                 return seed;
             }
 
-            private void replaceSiteSecretKey(String seed) throws IOException {
+            private void updateSiteSecretKey(String seed) throws Exception {
                 SecureRandom random = new SecureRandom(seed.getBytes(Config.getCharset()));
                 String secret = new BigInteger(130, random).toString(32);
 
-                Path path = Paths.get("conf/application.conf");
-                byte[] bytes = Files.readAllBytes(path);
+                if (configFile.isExternal()) {
+                    throw new Exception("Cowardly refusing to update an external file: " + configFile.getPath());
+                }
+
+                byte[] bytes = Files.readAllBytes(configFile.getPath());
                 String config = new String(bytes, Config.getCharset());
                 config = config.replace(DEFAULT_SECRET, secret);
-                Files.write(path, config.getBytes(Config.getCharset()));
+                Files.write(configFile.getPath(), config.getBytes(Config.getCharset()));
             }
 
             private boolean hasError(Form<User> newUserForm) {
@@ -273,4 +315,74 @@
         return Promise.pure((Result) badRequest(ErrorViews.BadRequest.render()));
     }
 
+    private static class ConfigFile {
+        private static final String CONFIG_DIRNAME = "conf";
+        private final String fileName;
+        private final String defaultFileName;
+        private final String propertyGroup;
+
+        ConfigFile(String propertyGroup, String fileName) {
+            this.propertyGroup = propertyGroup;
+            this.fileName = fileName;
+            this.defaultFileName = fileName + ".default";
+        }
+
+        String getProperty(@Nonnull String key) {
+            return System.getProperty(propertyGroup + "." + key);
+        }
+
+        String getProperty(@Nonnull String key, String defaultValue) {
+            return System.getProperty(propertyGroup + "." + key, defaultValue);
+        }
+
+        /**
+         * The location of the config file is specified by user
+         */
+        boolean isLocationSpecified() {
+            return (getProperty("resource") != null)
+                || (getProperty("file") != null)
+                || (getProperty("url") != null);
+        }
+
+        void createByDefault() throws IOException, URISyntaxException {
+            InputStream stream = Config.class.getClassLoader().getResourceAsStream(defaultFileName);
+
+            getPath().toFile().getParentFile().mkdirs();
+
+            if (stream != null) {
+                Files.copy(stream, getPath());
+            } else {
+                Files.copy(getDirectoryPath().resolve(defaultFileName), getPath());
+            }
+        }
+
+        /**
+         * @return the path to the configuration file
+         * @throws java.lang.IllegalStateException
+         */
+        Path getPath() throws URISyntaxException {
+            if (getProperty("url") != null) {
+                return Paths.get(new URI(getProperty("url")));
+            }
+
+            if (getProperty("file") != null) {
+                return Paths.get(getProperty("file"));
+            }
+
+            String filename = getProperty("resource", fileName);
+
+            return getDirectoryPath().resolve(filename);
+        }
+
+        /**
+         * @return the path to the directory to store configuration files
+         */
+        static Path getDirectoryPath() {
+            return Paths.get(CONFIG_DIRNAME);
+        }
+
+        boolean isExternal() throws IOException, URISyntaxException {
+            return !FileUtil.isSubpathOf(getPath(), getDirectoryPath());
+        }
+    }
 }
app/utils/FileUtil.java
--- app/utils/FileUtil.java
+++ app/utils/FileUtil.java
@@ -29,6 +29,7 @@
 
 import java.io.*;
 import java.nio.charset.Charset;
+import java.nio.file.Path;
 
 public class FileUtil {
 
@@ -145,4 +146,25 @@
         return mediaType.hasParameters()
                 ? mediaType.getParameters().get("charset") : null;
     }
+
+    /**
+     * Checks whether the subpath is a subpath of the given path
+     *
+     * @param subpath
+     * @param path
+     * @return true if the subpath is a subpath of the given path
+     * @throws IOException
+     */
+    public static boolean isSubpathOf(Path subpath, Path path) throws IOException {
+        return isSubpathOf(subpath, path, true);
+    }
+
+    public static boolean isSubpathOf(Path subpath, Path path, boolean resolveSymlink) throws IOException {
+        if (resolveSymlink) {
+            path = path.toRealPath();
+            subpath = subpath.toRealPath();
+        }
+
+        return subpath.normalize().startsWith(path.normalize());
+    }
 }
app/views/welcome/restart.scala.html
--- app/views/welcome/restart.scala.html
+++ app/views/welcome/restart.scala.html
@@ -18,6 +18,7 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **@
+@(hasFailedToUpdateSecret: Boolean = false)
 @import utils.TemplateHelper._
 <!DOCTYPE html>
 <html>
@@ -57,6 +58,9 @@
                 <h3>@Messages("app.restart.welcome")</h3>
                 <p class="secret-box txt-center">
                     @Html(Messages("app.restart.notice"))
+                    @if(hasFailedToUpdateSecret) {
+                        @Html(Messages("app.restart.updateSecretYourself"))
+                    }
                 </p>
             </div>
         </div>
conf/messages
--- conf/messages
+++ conf/messages
@@ -24,6 +24,7 @@
 app.description = Web-based platform for collaborative software development
 app.name = Yobi
 app.restart.notice = Server needs to be restarted.
+app.restart.updateSecretYourself = Please update application.secret with random text.
 app.restart.welcome = Welcome!
 app.welcome = Tada! Welcome to {0}!
 app.welcome.project = Project
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -24,6 +24,7 @@
 app.description = 웹기반 소프트웨어 개발 플랫폼
 app.name = Yobi
 app.restart.notice = 서버를 재시작해야합니다.
+app.restart.updateSecretYourself = application.secret을 무작위 문자열로 변경해주십시오.
 app.restart.welcome = 환영합니다!
 app.welcome = {0}를 시작합니다!
 app.welcome.project = 프로젝트
Add a comment
List