채수원 2014-07-29
Merge branch 'feature/update-notification' of dlab/hive
from pull request 1171
@3bfc204d75472798889856c11136d8a9e2c25958
app/Global.java
--- app/Global.java
+++ app/Global.java
@@ -69,6 +69,7 @@
         NotificationMail.onStart();
         NotificationEvent.onStart();
         Attachment.onStart();
+        YobiUpdate.onStart();
     }
 
     private boolean equalsDefaultSecret() {
app/assets/stylesheets/less/_common.less
--- app/assets/stylesheets/less/_common.less
+++ app/assets/stylesheets/less/_common.less
@@ -218,3 +218,18 @@
 .font-blue {
   color: #0088cc;
 }
+
+.notification-badge {
+    font-size:12px;
+    border:2px solid #FFF;
+    line-height:20px;
+    padding:0 5px;
+    background-color:@yobi-primary;
+    color:#ECF0F1;
+    .border-radius(10px);
+
+    -webkit-box-shadow: 0 1px 1px rgba(0,0,0,0.2), inset 0 1px 1px rgba(0,0,0,0.1);
+    -moz-box-shadow: 0 1px 1px rgba(0,0,0,0.2), inset 0 1px 1px rgba(0,0,0,0.1);
+    -o-box-shadow: 0 1px 1px rgba(0,0,0,0.2), inset 0 1px 1px rgba(0,0,0,0.1);
+    box-shadow: 0 1px 1px rgba(0,0,0,0.2), inset 0 1px 1px rgba(0,0,0,0.1);
+}
app/controllers/SiteApp.java
--- app/controllers/SiteApp.java
+++ app/controllers/SiteApp.java
@@ -31,6 +31,7 @@
 import org.apache.commons.mail.EmailException;
 import org.apache.commons.mail.SimpleEmail;
 import org.codehaus.jackson.node.ObjectNode;
+import org.eclipse.jgit.api.errors.GitAPIException;
 import play.Configuration;
 import play.Logger;
 import play.db.ebean.Transactional;
@@ -48,6 +49,8 @@
 import java.util.*;
 
 import static play.libs.Json.toJson;
+
+import utils.Config;
 
 /**
  * The Class SiteApp.
@@ -247,4 +250,30 @@
 
         return ok(toJson(emails));
     }
+
+    /**
+     * Hide the notification for Yobi updates.
+     */
+    public static Result unwatchUpdate() {
+        YobiUpdate.isWatched = false;
+        return ok();
+    }
+
+    /**
+     * Show the page to update Yobi.
+     */
+    public static Result update() throws GitAPIException {
+        String currentVersion = null;
+        Exception exception = null;
+
+        try {
+            currentVersion = Config.getCurrentVersion();
+            YobiUpdate.refreshVersionToUpdate();
+        } catch (Exception e) {
+            exception = e;
+        }
+
+        return ok(update.render("title.siteSetting", currentVersion,
+                    YobiUpdate.versionToUpdate, exception));
+    }
 }
 
app/models/YobiUpdate.java (added)
+++ app/models/YobiUpdate.java
@@ -0,0 +1,131 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2014 NAVER Corp.
+ * http://yobi.io
+ *
+ * @Author Yi EungJun
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package models;
+
+import com.github.zafarkhaja.semver.Version;
+import com.github.zafarkhaja.semver.util.UnexpectedElementTypeException;
+import com.typesafe.config.*;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.InvalidRemoteException;
+import org.eclipse.jgit.lib.Ref;
+import play.Configuration;
+import play.Logger;
+import play.libs.Akka;
+import scala.concurrent.duration.Duration;
+import utils.Config;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+public class YobiUpdate {
+    private static final Long UPDATE_NOTIFICATION_INITDELAY_IN_MILLIS = Configuration.root()
+            .getMilliseconds("application.update.notification.initdelay", 5 * 1000L);
+    private static final Long UPDATE_NOTIFICATION_INTERVAL_IN_MILLIS = Configuration.root()
+            .getMilliseconds("application.update.notification.interval", 60 * 60 * 1000L);
+    private static final String UPDATE_REPOSITORY_URL = Configuration.root()
+            .getString("application.update.repositoryUrl", "http://demo.yobi.io/naver/Yobi");
+    private static final String RELEASE_URL_FORMAT = Configuration.root()
+            .getString("application.update.releaseUrlFormat",
+                    "https://github.com/naver/yobi/releases/tag/v%s");
+
+    public static String versionToUpdate = null;
+
+    public static boolean isWatched = true;
+
+    public static void onStart() {
+        if (UPDATE_NOTIFICATION_INTERVAL_IN_MILLIS <= 0) {
+            return;
+        }
+
+        Akka.system().scheduler().schedule(
+            Duration.create(UPDATE_NOTIFICATION_INITDELAY_IN_MILLIS, TimeUnit.MILLISECONDS),
+            Duration.create(UPDATE_NOTIFICATION_INTERVAL_IN_MILLIS, TimeUnit.MILLISECONDS),
+            new Runnable() {
+                public void run() {
+                    try {
+                        refreshVersionToUpdate();
+                    } catch (Exception e) {
+                        play.Logger.warn("Failed to fetch the latest Yobi version to update", e);
+                    }
+                }
+            },
+            Akka.system().dispatcher()
+        );
+    }
+
+    public static String getReleaseUrl() throws GitAPIException {
+        return getReleaseUrl(versionToUpdate);
+    }
+
+    public static String getReleaseUrl(String version) throws GitAPIException {
+        return String.format(RELEASE_URL_FORMAT, version);
+    }
+
+    public static void refreshVersionToUpdate() throws GitAPIException {
+        versionToUpdate = fetchVersionToUpdate();
+    }
+
+    /**
+     * Fetch the latest version to update.
+     *
+     * @Return  a string to represent the version to update; null if there is
+     *          no version to update
+     */
+    public static String fetchVersionToUpdate() throws GitAPIException {
+        Version current = Version.valueOf(Config.semverize(Config.getCurrentVersion()));
+        Version latest = current;
+        boolean isUpdateRequired = false;
+
+        Collection<Ref> refs;
+
+        refs = Git.lsRemoteRepository()
+                .setRemote(UPDATE_REPOSITORY_URL)
+                .setHeads(false)
+                .setTags(true)
+                .call();
+
+        for(Ref ref : refs) {
+            String tag = ref.getName().replaceFirst("^refs/tags/", "");
+            if (tag.charAt(0) == 'v') {
+                String versionString = Config.semverize(tag);
+
+                try {
+                    Version ver = Version.valueOf(versionString);
+                    if (ver.greaterThan(latest)) {
+                        isUpdateRequired = true;
+                        latest = ver;
+                    }
+                } catch (UnexpectedElementTypeException e) {
+                    play.Logger.warn("Failed to parse a version: " +
+                            versionString);
+                }
+            }
+        }
+
+        if (isUpdateRequired) {
+            return latest.toString();
+        } else {
+            return null;
+        }
+    }
+}
app/utils/Config.java
--- app/utils/Config.java
+++ app/utils/Config.java
@@ -20,11 +20,14 @@
  */
 package utils;
 
+import com.typesafe.config.*;
 import org.apache.commons.lang3.ObjectUtils;
 import play.Configuration;
 import play.mvc.Http;
 
+import java.io.File;
 import java.net.*;
+import java.nio.file.Paths;
 import java.util.Enumeration;
 
 public class Config {
@@ -208,4 +211,41 @@
     public static URI createFullURI(String uri) throws URISyntaxException {
         return createFullURI(new URI(uri));
     }
+
+    /**
+     * Convert the given version to be compatible with
+     * <a href="http://semver.org/">semver</a>.
+     *
+     * Examples:
+     *   v0.5.7 -> 0.5.7
+     *   0.5 -> 0.5.0
+     *   0.4.0.pre -> 0.4.0-pre
+     *
+     * @param ver  a version string to be semverized
+     * @return     the semverized string
+     */
+    public static String semverize(String ver) {
+        // v0.5.7 -> 0.5.7
+        ver = ver.replaceFirst("^v", "");
+
+        // 0.4.0.pre -> 0.4.0-pre
+        ver = ver.replaceAll("\\.([^\\d]+)","-$1");
+
+        // 0.5 -> 0.5.0
+        // 0.5-alpha -> 0.5.0-alpha
+        ver = ver.replaceFirst("^(\\d+\\.\\d+)($|-)", "$1.0$2");
+
+        return ver;
+    }
+
+    /**
+     * Return the version of Yobi installed currently.
+     *
+     * @return  the current version
+     */
+    public static String getCurrentVersion() {
+        File versionFile = Paths.get("conf", "version.conf").toFile();
+
+        return ConfigFactory.parseFile(versionFile).resolve().getString("app.version");
+    }
 }
app/views/layout.scala.html
--- app/views/layout.scala.html
+++ app/views/layout.scala.html
@@ -40,6 +40,7 @@
 </head>
 
 <body class="@theme">
+@partial_update_notification()
 @content
 @common.scripts()
 </body>
 
app/views/partial_update_notification.scala.html (added)
+++ app/views/partial_update_notification.scala.html
@@ -0,0 +1,29 @@
+@**
+* Yobi, Project Hosting SW
+*
+* Copyright 2014 NAVER Corp.
+* http://yobi.io
+*
+* @Author Yi EungJun
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*   http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+**@
+
+@import models.YobiUpdate
+
+@if(UserApp.currentUser().isSiteManager() && YobiUpdate.isWatched && YobiUpdate.versionToUpdate != null){
+<p class="center-txt">
+<a href="@YobiUpdate.getReleaseUrl()">@Messages("site.update.notification", YobiUpdate.versionToUpdate)</a>
+<button type="button" data-request-method="post" data-request-uri="@routes.SiteApp.unwatchUpdate()" class="ybtn ybtn-small">@Messages("site.update.notification.hide", YobiUpdate.versionToUpdate)</button>
+</p>
+}
app/views/site/siteMngLayout.scala.html
--- app/views/site/siteMngLayout.scala.html
+++ app/views/site/siteMngLayout.scala.html
@@ -59,6 +59,11 @@
                     <li class="@isActiveMenu(routes.SiteApp.massMail())">
                         <a href="@routes.SiteApp.massMail()">@Messages("site.sidebar.massMail")</a>
                     </li>
+                    <li class="@isActiveMenu(routes.SiteApp.update())">
+                        <a href="@routes.SiteApp.update()">@Messages("site.sidebar.update")
+                        @if(YobiUpdate.versionToUpdate != null) { <span class="notification-badge">1</span> }
+                        </a>
+                    </li>
                 </ul>
             </div>
             <div class="span10">
 
app/views/site/update.scala.html (added)
+++ app/views/site/update.scala.html
@@ -0,0 +1,48 @@
+@**
+* Yobi, Project Hosting SW
+*
+* Copyright 2014 NAVER Corp.
+* http://yobi.io
+*
+* @Author Yi EungJun
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*   http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+**@
+@(message: String, currentVersion: String, versionToUpdate: String, exception: Exception = null)
+
+@import org.apache.commons.lang.exception.ExceptionUtils
+@import utils.TemplateHelper._
+
+@siteMngLayout(message) {
+    <div class="title_area">
+        <h2 class="pull-left">@Messages("site.sidebar.update")</h2>
+    </div>
+
+    @if(versionToUpdate != null){
+      <p><strong>@Messages("site.update.isAvailable", versionToUpdate)</strong>
+      <a href="@YobiUpdate.getReleaseUrl(versionToUpdate)" class="ybtn ybtn-success">@Messages("site.update.download")</a></p>
+    }
+
+    @if(currentVersion != null){
+      <p>@Messages("site.update.currentVersion", currentVersion)</p>
+    }
+
+    @if(versionToUpdate == null && exception == null) {
+      <p>@Messages("site.update.isNotNecessary", currentVersion)</p>
+    }
+
+    @if(exception != null){
+      <p>@Messages("site.update.error")</p>
+      <pre>@ExceptionUtils.getStackTrace(exception)</pre>
+    }
+}
conf/application.conf.default
--- conf/application.conf.default
+++ conf/application.conf.default
@@ -127,6 +127,16 @@
 # If this value is undefined or not positive number, notifications will remain forever.
 # application.notification.keep-time = 60
 
+# Software Update
+# ~~~~~~~~~~~~~~~
+# Check for updates of Yobi at this interval if it is grater than 0.
+application.update.notification.interval = 1d
+# A url to the git repository for Yobi releases.
+application.update.repositoryUrl = "https://github.com/naver/yobi"
+# A format to construct the url to latest Yobi release. "%s" is a format
+# specifier for Yobi version to download like "0.5.7".
+application.update.releaesUrlFormat = "https://github.com/naver/yobi/releases/tag/v%s"
+
 #customize play default thread pool size
 play {
   akka {
conf/messages
--- conf/messages
+++ conf/messages
@@ -679,6 +679,14 @@
 site.sidebar.postList = Posts
 site.sidebar.projectList = Projects
 site.sidebar.userList = Users
+site.sidebar.update = Software Update
+site.update.currentVersion = Current version is Yobi {0}
+site.update.download = Download
+site.update.isAvailable = Yobi {0} is available
+site.update.isNotNecessary = You are using the latest version
+site.update.error = Failed to check for updates because of the following error:
+site.update.notification = Software update: Yobi {0} is available
+site.update.notification.hide = Hide
 site.user.delete = Delete user
 site.user.deleteConfirm = Are you sure you want this user to leave?
 site.userList.deleteAlert = This user cannot be deleted because this user is the only manager of the project.
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -679,6 +679,14 @@
 site.sidebar.postList = 게시물
 site.sidebar.projectList = 프로젝트
 site.sidebar.userList = 사용자
+site.sidebar.update = 업데이트
+site.update.currentVersion = 현재 버전은 {0} 입니다
+site.update.download = 다운로드
+site.update.error = 다음과 같이 에러가 발생하여 업데이트 할 버전을 확인하지 못했습니다.
+site.update.isAvailable = Yobi {0} 버전으로 업데이트 할 수 있습니다
+site.update.isNotNecessary = 현재 최신 버전을 사용중입니다
+site.update.notification = 업데이트 알림: Yobi {0} 버전이 나왔습니다
+site.update.notification.hide = 숨기기
 site.user.delete = 사용자 삭제
 site.user.deleteConfirm = 정말로 해당 사용자를 사이트에서 탈퇴시키겠습니까?
 site.userList.deleteAlert = 프로젝트의 유일한 관리자이므로 사이트에서 삭제할 수 없습니다.
conf/routes
--- conf/routes
+++ conf/routes
@@ -71,6 +71,8 @@
 GET            /sites/projectList                                                     controllers.SiteApp.projectList(filter:String ?= "", pageNum: Int ?=0)
 DELETE         /sites/project/delete/:projectId                                       controllers.SiteApp.deleteProject(projectId:Long)
 POST           /sites/toggleAccountLock                                               controllers.SiteApp.toggleAccountLock(loginId: String, state: String ?= null, query: String ?= null)
+GET            /sites/update                                                          controllers.SiteApp.update()
+POST           /sites/unwatchUpdate                                                   controllers.SiteApp.unwatchUpdate()
 GET            /lostPassword                                                          controllers.PasswordResetApp.lostPassword
 POST           /lostPassword                                                          controllers.PasswordResetApp.requestResetPasswordEmail()
 GET            /resetPassword                                                         controllers.PasswordResetApp.resetPasswordForm(s:String)
project/Build.scala
--- project/Build.scala
+++ project/Build.scala
@@ -52,7 +52,8 @@
       "commons-collections" % "commons-collections" % "3.2.1",
       "org.jsoup" % "jsoup" % "1.7.2",
       "com.googlecode.juniversalchardet" % "juniversalchardet" % "1.0.3",
-      "org.mockito" % "mockito-all" % "1.9.0" % "test"
+      "org.mockito" % "mockito-all" % "1.9.0" % "test",
+      "com.github.zafarkhaja" % "java-semver" % "0.7.2"
   )
 
     val projectSettings = Seq(
test/utils/ConfigTest.java
--- test/utils/ConfigTest.java
+++ test/utils/ConfigTest.java
@@ -100,4 +100,13 @@
         assertThat(Config.getSiteName()).isEqualTo("Yobi");
         Helpers.stop(app);
     }
+
+    @Test
+    public void semverize() {
+        assertThat(Config.semverize("0.5.7")).isEqualTo("0.5.7");
+        assertThat(Config.semverize("v0.5.7")).isEqualTo("0.5.7");
+        assertThat(Config.semverize("0.5")).isEqualTo("0.5.0");
+        assertThat(Config.semverize("0.4.0.pre")).isEqualTo("0.4.0-pre");
+        assertThat(Config.semverize("0.5-alpha")).isEqualTo("0.5.0-alpha");
+    }
 }
Add a comment
List