Explorar o código

Merge branch 'master' into imprv/kibela-like-theme

mayu morita %!s(int64=7) %!d(string=hai) anos
pai
achega
69f40719c8
Modificáronse 43 ficheiros con 934 adicións e 240 borrados
  1. 3 1
      CHANGES.md
  2. 3 1
      README.md
  3. 4 1
      config/logger/config.dev.js
  4. 0 1
      config/webpack.common.js
  5. 1 0
      lib/crowi/index.js
  6. 13 0
      lib/form/admin/securityPassportTwitter.js
  7. 1 0
      lib/form/index.js
  8. 12 2
      lib/locales/en-US/translation.json
  9. 11 1
      lib/locales/ja/translation.json
  10. 6 0
      lib/models/config.js
  11. 75 80
      lib/models/page.js
  12. 28 0
      lib/routes/admin.js
  13. 14 1
      lib/routes/hackmd.js
  14. 3 0
      lib/routes/index.js
  15. 37 0
      lib/routes/login-passport.js
  16. 52 27
      lib/routes/page.js
  17. 47 1
      lib/service/passport.js
  18. 5 0
      lib/util/swigFunctions.js
  19. 0 5
      lib/views/_form.html
  20. 3 3
      lib/views/admin/security.html
  21. 1 1
      lib/views/admin/widget/passport/github.html
  22. 1 1
      lib/views/admin/widget/passport/google-oauth.html
  23. 115 4
      lib/views/admin/widget/passport/twitter.html
  24. 2 0
      lib/views/widget/not_found_content.html
  25. 1 3
      lib/views/widget/page_content.html
  26. 2 2
      lib/views/widget/user_page_header.html
  27. 2 1
      package.json
  28. 146 12
      resource/js/app.js
  29. 10 8
      resource/js/components/PageComment/CommentForm.js
  30. 1 4
      resource/js/components/PageEditor/CodeMirrorEditor.js
  31. 0 5
      resource/js/components/PageEditor/Editor.js
  32. 68 9
      resource/js/components/PageEditorByHackmd.jsx
  33. 2 2
      resource/js/components/PageEditorByHackmd/HackmdEditor.jsx
  34. 143 0
      resource/js/components/PageStatusAlert.jsx
  35. 0 1
      resource/js/components/SlackNotification.jsx
  36. 5 1
      resource/js/hackmd-agent.js
  37. 0 14
      resource/js/legacy/crowi-form.js
  38. 31 42
      resource/js/legacy/crowi.js
  39. 6 5
      resource/js/util/Crowi.js
  40. 25 1
      resource/styles/scss/_on-edit.scss
  41. 4 0
      resource/styles/scss/_page.scss
  42. 26 0
      resource/styles/scss/_user.scss
  43. 25 0
      yarn.lock

+ 3 - 1
CHANGES.md

@@ -3,7 +3,9 @@ CHANGES
 
 
 ## 3.2.0-RC
 ## 3.2.0-RC
 
 
-* Feature: HackMD integration so that user can simultaneously edit with multiple people
+* Feature: HackMD integration so that user will be able to simultaneously edit with multiple people
+* Feature: Login with Twitter Account
+* Fix: The Initial scroll position is wrong when reloading the page
 
 
 ## 3.1.14
 ## 3.1.14
 
 

+ 3 - 1
README.md

@@ -41,7 +41,8 @@ Features
   * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
   * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
 * **Features**
 * **Features**
   * Create hierarchical pages with markdown
   * Create hierarchical pages with markdown
-  * Support Authentication with LDAP / Active Directory 
+  * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
+  * Support Authentication with LDAP / Active Directory
   * Slack Incoming Webhooks Integration
   * Slack Incoming Webhooks Integration
   * [Miscellaneous features](https://github.com/weseek/growi/wiki/Additional-Features)
   * [Miscellaneous features](https://github.com/weseek/growi/wiki/Additional-Features)
 * **[Docker Ready][dockerhub]**
 * **[Docker Ready][dockerhub]**
@@ -165,6 +166,7 @@ Environment Variables
     * FILE_UPLOAD: `aws` (default), `local`, `none`
     * FILE_UPLOAD: `aws` (default), `local`, `none`
 * **Option to integrate with external systems**
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
+        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/management-cookbook/integrate-with-hackmd).**
     * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
     * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.

+ 4 - 1
config/logger/config.dev.js

@@ -1,7 +1,7 @@
 module.exports = {
 module.exports = {
   default: 'info',
   default: 'info',
 
 
-  //// configure level for name
+  //// configure level for server
   // 'express:*': 'debug',
   // 'express:*': 'debug',
   // 'growi:*': 'debug',
   // 'growi:*': 'debug',
   'growi:crowi': 'debug',
   'growi:crowi': 'debug',
@@ -14,4 +14,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   // 'growi:routes:page': 'debug',
   // 'growi-plugin:*': 'debug',
   // 'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
   // 'growi:InterceptorManager': 'debug',
+
+  //// configure level for client
+  'growi:app': 'debug',
 };
 };

+ 0 - 1
config/webpack.common.js

@@ -22,7 +22,6 @@ module.exports = (options) => {
     entry: Object.assign({
     entry: Object.assign({
       'js/app':                   './resource/js/app',
       'js/app':                   './resource/js/app',
       'js/legacy':                './resource/js/legacy/crowi',
       'js/legacy':                './resource/js/legacy/crowi',
-      'js/legacy-form':           './resource/js/legacy/crowi-form',
       'js/legacy-admin':          './resource/js/legacy/crowi-admin',
       'js/legacy-admin':          './resource/js/legacy/crowi-admin',
       'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
       'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
       'js/plugin':                './resource/js/plugin',
       'js/plugin':                './resource/js/plugin',

+ 1 - 0
lib/crowi/index.js

@@ -276,6 +276,7 @@ Crowi.prototype.setupPassport = function() {
     this.passportService.setupLdapStrategy();
     this.passportService.setupLdapStrategy();
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
     this.passportService.setupGitHubStrategy();
+    this.passportService.setupTwitterStrategy(); 
   }
   }
   catch (err) {
   catch (err) {
     logger.error(err);
     logger.error(err);

+ 13 - 0
lib/form/admin/securityPassportTwitter.js

@@ -0,0 +1,13 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-twitter:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-twitter:consumerKey]').trim(),
+  field('settingForm[security:passport-twitter:consumerSecret]').trim(),
+  field('settingForm[security:passport-twitter:callbackUrl]').trim(),
+  field('settingForm[security:passport-twitter:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+);

+ 1 - 0
lib/form/index.js

@@ -21,6 +21,7 @@ module.exports = {
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
+    securityPassportTwitter: require('./admin/securityPassportTwitter'),
     markdown: require('./admin/markdown'),
     markdown: require('./admin/markdown'),
     markdownXss: require('./admin/markdownXss'),
     markdownXss: require('./admin/markdownXss'),
     customcss: require('./admin/customcss'),
     customcss: require('./admin/customcss'),

+ 12 - 2
lib/locales/en-US/translation.json

@@ -389,13 +389,23 @@
         "name": "Facebook OAuth"
         "name": "Facebook OAuth"
       },
       },
       "Twitter": {
       "Twitter": {
-        "name": "Twitter OAuth"
+        "name": "Twitter OAuth",
+        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_2": "Sign in Twitter",
+        "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       },
       "GitHub": {
       "GitHub": {
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
-        "register_3": "Copy and paste your ClientID and Client Secret above"
+        "register_3": "Copy and paste your ClientID and Client Secret above"     
+      },
+      "how_to": {
+        "google": "How to configure Google OAuth?",
+        "github": "How to configure GitHub OAuth?",
+        "twitter": "How to configure Twitter OAuth?"
       }
       }
     }
     }
 	},
 	},

+ 11 - 1
lib/locales/ja/translation.json

@@ -406,13 +406,23 @@
         "name": "Facebook OAuth認証"
         "name": "Facebook OAuth認証"
       },
       },
       "Twitter": {
       "Twitter": {
-        "name": "Twitter OAuth認証"
+        "name": "Twitter OAuth認証",
+        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_2": "Twitterにサインイン",
+        "register_3": "Create New Appをクリック &rightarrow; Application Detailsの各項目を入力",
+        "register_4": "Create your Twitter Applicationで作成",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       },
       "GitHub": {
       "GitHub": {
         "name": "GitHub OAuth認証",
         "name": "GitHub OAuth認証",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
         "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
+      },
+      "how_to": {
+        "google": "Google OAuthの設定方法",
+        "github": "GitHub OAuthの設定方法",
+        "twitter": "Twitter OAuthの設定方法"
       }
       }
     }
     }
   },
   },

+ 6 - 0
lib/models/config.js

@@ -68,6 +68,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
       'security:passport-google:isEnabled' : false,
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
+      'security:passport-twitter:isEnabled' : false,
 
 
       'aws:bucket'          : 'growi',
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',
       'aws:region'          : 'ap-northeast-1',
@@ -285,6 +286,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
+   configSchema.statics.isEnabledPassportTwitter = function(config) {
+    const key = 'security:passport-twitter:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
   configSchema.statics.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
     const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
     const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);

+ 75 - 80
lib/models/page.js

@@ -950,46 +950,25 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
-    var isCreate = false;
-    if (pageData.revision === undefined) {
-      debug('pushRevision on Create');
-      isCreate = true;
-    }
+  pageSchema.statics.pushRevision = async function(pageData, newRevision, user) {
+    await newRevision.save();
+    debug('Successfully saved new revision', newRevision);
 
 
-    return new Promise(function(resolve, reject) {
-      newRevision.save(function(err, newRevision) {
-        if (err) {
-          debug('Error on saving revision', err);
-          return reject(err);
-        }
+    pageData.revision = newRevision;
+    pageData.lastUpdateUser = user;
+    pageData.updatedAt = Date.now();
 
 
-        debug('Successfully saved new revision', newRevision);
-        pageData.revision = newRevision;
-        pageData.lastUpdateUser = user;
-        pageData.updatedAt = Date.now();
-        pageData.save(function(err, data) {
-          if (err) {
-            // todo: remove new revision?
-            debug('Error on save page data (after push revision)', err);
-            return reject(err);
-          }
-
-          resolve(data);
-          if (!isCreate) {
-            debug('pushRevision on Update');
-          }
-        });
-      });
-    });
+    return pageData.save();
   };
   };
 
 
-  pageSchema.statics.create = function(path, body, user, options) {
+  pageSchema.statics.create = function(path, body, user, options = {}) {
     const Page = this
     const Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , format = options.format || 'markdown'
       , redirectTo = options.redirectTo || null
       , redirectTo = options.redirectTo || null
-      , grantUserGroupId = options.grantUserGroupId || null;
+      , grantUserGroupId = options.grantUserGroupId || null
+      , socketClientId = options.socketClientId || null
+      ;
 
 
     let grant = options.grant || GRANT_PUBLIC;
     let grant = options.grant || GRANT_PUBLIC;
 
 
@@ -1033,34 +1012,47 @@ module.exports = function(crowi) {
         return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
         return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
       })
       })
       .then(() => {
       .then(() => {
-        pageEvent.emit('create', savedPage, user);
+        if (socketClientId != null) {
+          pageEvent.emit('create', savedPage, user, socketClientId);
+        }
         return savedPage;
         return savedPage;
       });
       });
   };
   };
 
 
-  pageSchema.statics.updatePage = async function(pageData, body, user, options) {
-    var Page = this
+  pageSchema.statics.updatePage = async function(pageData, body, user, options = {}) {
+    const Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , grant = options.grant || null
       , grant = options.grant || null
       , grantUserGroupId = options.grantUserGroupId || null
       , grantUserGroupId = options.grantUserGroupId || null
+      , isSyncRevisionToHackmd = options.isSyncRevisionToHackmd
+      , socketClientId = options.socketClientId || null
       ;
       ;
+
     // update existing page
     // update existing page
-    var newRevision = await Revision.prepareRevision(pageData, body, user);
+    const newRevision = await Revision.prepareRevision(pageData, body, user);
 
 
     const revision = await Page.pushRevision(pageData, newRevision, user);
     const revision = await Page.pushRevision(pageData, newRevision, user);
-    const savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
+    let savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
     if (grant != null) {
     if (grant != null) {
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       debug('Page grant update:', grantData);
       debug('Page grant update:', grantData);
     }
     }
-    pageEvent.emit('update', savedPage, user);
+
+    if (isSyncRevisionToHackmd) {
+      savedPage = await Page.syncRevisionToHackmd(savedPage);
+    }
+
+    if (socketClientId != null) {
+      pageEvent.emit('update', savedPage, user, socketClientId);
+    }
     return savedPage;
     return savedPage;
   };
   };
 
 
-  pageSchema.statics.deletePage = function(pageData, user, options) {
-    var Page = this
+  pageSchema.statics.deletePage = async function(pageData, user, options = {}) {
+    const Page = this
       , newPath = Page.getDeletedPageName(pageData.path)
       , newPath = Page.getDeletedPageName(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)
+      , socketClientId = options.socketClientId || null
       ;
       ;
 
 
     if (Page.isDeletableName(pageData.path)) {
     if (Page.isDeletableName(pageData.path)) {
@@ -1068,13 +1060,13 @@ module.exports = function(crowi) {
         return Page.completelyDeletePage(pageData, user, options);
         return Page.completelyDeletePage(pageData, user, options);
       }
       }
 
 
-      return Page.rename(pageData, newPath, user, {createRedirectPage: true})
-        .then((updatedPageData) => {
-          return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
-        })
-        .then(() => {
-          return pageData;
-        });
+      let updatedPageData = await Page.rename(pageData, newPath, user, {createRedirectPage: true});
+      await Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
+
+      if (socketClientId != null) {
+        pageEvent.emit('delete', updatedPageData, user, socketClientId);
+      }
+      return updatedPageData;
     }
     }
     else {
     else {
       return Promise.reject('Page is not deletable.');
       return Promise.reject('Page is not deletable.');
@@ -1086,11 +1078,11 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , path = pageData.path
       , path = pageData.path
-      , options = options || {}
-      , isTrashed = checkIfTrashed(pageData.path);
+      , isTrashed = checkIfTrashed(pageData.path)
       ;
       ;
+    options = options || {};
 
 
     if (isTrashed) {
     if (isTrashed) {
       return Page.completelyDeletePageRecursively(pageData, user, options);
       return Page.completelyDeletePageRecursively(pageData, user, options);
@@ -1109,7 +1101,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
   pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , newPath = Page.getRevertDeletedPageName(pageData.path)
       , newPath = Page.getRevertDeletedPageName(pageData.path)
       ;
       ;
 
 
@@ -1123,7 +1115,7 @@ module.exports = function(crowi) {
           throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
           throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
         }
         }
 
 
-        return Page.completelyDeletePage(originPageData);
+        return Page.completelyDeletePage(originPageData, options);
       }).then(function(done) {
       }).then(function(done) {
         return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user});
         return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user});
       }).then(function(done) {
       }).then(function(done) {
@@ -1138,11 +1130,11 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.revertDeletedPageRecursively = function(pageData, user, options) {
-    var Page = this
+  pageSchema.statics.revertDeletedPageRecursively = function(pageData, user, options = {}) {
+    const Page = this
       , path = pageData.path
       , path = pageData.path
-      , options = options || { includeDeletedPage: true}
       ;
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Page
       Page
@@ -1162,15 +1154,16 @@ module.exports = function(crowi) {
   /**
   /**
    * This is danger.
    * This is danger.
    */
    */
-  pageSchema.statics.completelyDeletePage = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePage = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Bookmark = crowi.model('Bookmark')
+    const Bookmark = crowi.model('Bookmark')
       , Attachment = crowi.model('Attachment')
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , PageGroupRelation = crowi.model('PageGroupRelation')
       , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , Page = this
       , pageId = pageData._id
       , pageId = pageData._id
+      , socketClientId = options.socketClientId || null
       ;
       ;
 
 
     debug('Completely delete', pageData.path);
     debug('Completely delete', pageData.path);
@@ -1191,18 +1184,20 @@ module.exports = function(crowi) {
       }).then(function(done) {
       }).then(function(done) {
         return PageGroupRelation.removeAllByPage(pageData);
         return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
       }).then(function(done) {
-        pageEvent.emit('delete', pageData, user); // update as renamed page
+        if (socketClientId != null) {
+          pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
+        }
         resolve(pageData);
         resolve(pageData);
       }).catch(reject);
       }).catch(reject);
     });
     });
   };
   };
 
 
-  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Page = this
+    const Page = this
       , path = pageData.path
       , path = pageData.path
-      , options = options || { includeDeletedPage: true }
       ;
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Page
       Page
@@ -1270,32 +1265,31 @@ module.exports = function(crowi) {
       });
       });
   };
   };
 
 
-  pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
+  pageSchema.statics.rename = async function(pageData, newPagePath, user, options) {
     const Page = this
     const Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , path = pageData.path
       , path = pageData.path
       , createRedirectPage = options.createRedirectPage || 0
       , createRedirectPage = options.createRedirectPage || 0
+      , socketClientId = options.socketClientId || null
       ;
       ;
 
 
     // sanitize path
     // sanitize path
     newPagePath = crowi.xss.process(newPagePath);
     newPagePath = crowi.xss.process(newPagePath);
 
 
-    return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
-      .then((data) => {
+    await Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user});
         // reivisions の path を変更
         // reivisions の path を変更
-        return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      })
-      .then(function(data) {
-        pageData.path = newPagePath;
+    await Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
 
 
         if (createRedirectPage) {
         if (createRedirectPage) {
           const body = 'redirect ' + newPagePath;
           const body = 'redirect ' + newPagePath;
-          Page.create(path, body, user, {redirectTo: newPagePath});
+      await Page.create(path, body, user, {redirectTo: newPagePath});
         }
         }
-        pageEvent.emit('update', pageData, user); // update as renamed page
 
 
-        return pageData;
-      });
+    let updatedPageData = await Page.findOne({path: newPagePath});
+    pageEvent.emit('delete', pageData, user, socketClientId);
+    pageEvent.emit('create', updatedPageData, user, socketClientId);
+
+    return updatedPageData;
   };
   };
 
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1337,11 +1331,17 @@ module.exports = function(crowi) {
   /**
   /**
    * update revisionHackmdSynced
    * update revisionHackmdSynced
    * @param {Page} pageData
    * @param {Page} pageData
+   * @param {bool} isSave whether save or not
    */
    */
-  pageSchema.statics.syncRevisionToHackmd = function(pageData) {
+  pageSchema.statics.syncRevisionToHackmd = function(pageData, isSave = true) {
     pageData.revisionHackmdSynced = pageData.revision;
     pageData.revisionHackmdSynced = pageData.revision;
     pageData.hasDraftOnHackmd = false;
     pageData.hasDraftOnHackmd = false;
-    return pageData.save();
+
+    let returnData = pageData;
+    if (isSave) {
+      returnData = pageData.save();
+    }
+    return returnData;
   };
   };
 
 
   /**
   /**
@@ -1352,12 +1352,7 @@ module.exports = function(crowi) {
    * @param {Boolean} newValue
    * @param {Boolean} newValue
    */
    */
   pageSchema.statics.updateHasDraftOnHackmd = async function(pageData, newValue) {
   pageSchema.statics.updateHasDraftOnHackmd = async function(pageData, newValue) {
-    const revisionIdStr = pageData.revision.toString();
-    const revisionHackmdSyncedIdStr = pageData.revisionHackmdSynced.toString();
-    if (revisionIdStr !== revisionHackmdSyncedIdStr) {
-      return;
-    }
-    else if (pageData.hasDraftOnHackmd === newValue) {
+    if (pageData.hasDraftOnHackmd === newValue) {
       // do nothing when hasDraftOnHackmd equals to newValue
       // do nothing when hasDraftOnHackmd equals to newValue
       return;
       return;
     }
     }

+ 28 - 0
lib/routes/admin.js

@@ -1089,6 +1089,34 @@ module.exports = function(crowi, app) {
     return res.json({status: true});
     return res.json({status: true});
   };
   };
 
 
+  actions.api.securityPassportTwitterSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+    
+
+    // reset strategy
+    await crowi.passportService.resetTwitterStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportTwitter(config)) {
+      try {
+        await crowi.passportService.setupTwitterStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetTwitterStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
   actions.api.customizeSetting = function(req, res) {
   actions.api.customizeSetting = function(req, res) {
     const form = req.form.settingForm;
     const form = req.form.settingForm;
 
 

+ 14 - 1
lib/routes/hackmd.js

@@ -7,7 +7,9 @@ const axios = require('axios');
 const ApiResponse = require('../util/apiResponse');
 const ApiResponse = require('../util/apiResponse');
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
+  const config = crowi.getConfig();
   const Page = crowi.models.Page;
   const Page = crowi.models.Page;
+  const pageEvent = crowi.event('page');
 
 
   // load GROWI agent script for HackMD
   // load GROWI agent script for HackMD
   const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
   const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
@@ -17,6 +19,10 @@ module.exports = function(crowi, app) {
   let agentScriptContentTpl = undefined;
   let agentScriptContentTpl = undefined;
   let stylesScriptContentTpl = undefined;
   let stylesScriptContentTpl = undefined;
 
 
+  // init 'saveOnHackmd' event
+  pageEvent.on('saveOnHackmd', function(page) {
+    crowi.getIo().sockets.emit('page:editingWithHackmd', {page});
+  });
 
 
   /**
   /**
    * GET /_hackmd/load-agent
    * GET /_hackmd/load-agent
@@ -33,7 +39,13 @@ module.exports = function(crowi, app) {
       agentScriptContentTpl = swig.compileFile(agentScriptPath);
       agentScriptContentTpl = swig.compileFile(agentScriptPath);
     }
     }
 
 
-    const origin = `${req.protocol}://${req.get('host')}`;
+    let origin = `${req.protocol}://${req.get('host')}`;
+
+    // use config.crowi['app:url'] when exist req.headers['x-forwarded-proto'].
+    // refs: lib/crowi/express-init.js
+    if (config.crowi && config.crowi['app:url']) {
+      origin = config.crowi['app:url'];
+    }
 
 
     // generate definitions to replace
     // generate definitions to replace
     const definitions = {
     const definitions = {
@@ -165,6 +177,7 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       await Page.updateHasDraftOnHackmd(page, true);
       await Page.updateHasDraftOnHackmd(page, true);
+      pageEvent.emit('saveOnHackmd', page);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
     }
     }
     catch (err) {
     catch (err) {

+ 3 - 0
lib/routes/index.js

@@ -71,10 +71,13 @@ module.exports = function(crowi, app) {
   // OAuth
   // OAuth
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
+  app.post('/_api/admin/security/passport-twitter' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
+  app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
+  app.get('/passport/twitter/callback'             , loginPassport.loginPassportTwitterCallback); 
 
 
   // markdown admin
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);

+ 37 - 0
lib/routes/login-passport.js

@@ -277,6 +277,41 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  const loginWithTwitter = function(req, res, next) {
+    if (!passportService.isTwitterStrategySetup) {
+      debug('TwitterStrategy has not been set up');
+      req.flash('warningMessage', 'TwitterStrategy has not been set up');
+      return next();
+    }
+
+    passport.authenticate('twitter')(req, res);
+  };
+
+  const loginPassportTwitterCallback = async(req, res, next) => {
+    const providerId = 'twitter';
+    const strategyName = 'twitter';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response.id,
+      'username': response.username,
+      'name': response.displayName
+    };
+
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) }
+      return loginSuccess(req, res, user);
+    });
+  };
+
+
   const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
   const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
       passport.authenticate(strategyName, (err, response, info) => {
       passport.authenticate(strategyName, (err, response, info) => {
@@ -336,7 +371,9 @@ module.exports = function(crowi, app) {
     loginWithLocal,
     loginWithLocal,
     loginWithGoogle,
     loginWithGoogle,
     loginWithGitHub,
     loginWithGitHub,
+    loginWithTwitter,
     loginPassportGoogleCallback,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportGitHubCallback,
+    loginPassportTwitterCallback,
   };
   };
 };
 };

+ 52 - 27
lib/routes/page.js

@@ -23,11 +23,28 @@ module.exports = function(crowi, app) {
   // register page events
   // register page events
 
 
   const pageEvent = crowi.event('page');
   const pageEvent = crowi.event('page');
-  pageEvent.on('update', function(page, user) {
-    crowi.getIo().sockets.emit('page edited', {page, user});
+  pageEvent.on('create', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:create', {page, user, socketClientId});
+  });
+  pageEvent.on('update', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:update', {page, user, socketClientId});
+  });
+  pageEvent.on('delete', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:delete', {page, user, socketClientId});
   });
   });
 
 
 
 
+  function serializeToObj(page) {
+    const returnObj = page.toObject();
+    if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
+      returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
+    }
+    return returnObj;
+  }
+
   function getPathFromRequest(req) {
   function getPathFromRequest(req) {
     const path = '/' + (req.params[0] || '');
     const path = '/' + (req.params[0] || '');
     return path.replace(/\.md$/, '');
     return path.replace(/\.md$/, '');
@@ -776,6 +793,7 @@ module.exports = function(crowi, app) {
     const grantUserGroupId = req.body.grantUserGroupId || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
     const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
     const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const slackChannels = req.body.slackChannels || null;
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     if (body === null || pagePath === null) {
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
       return res.json(ApiResponse.error('Parameters body and path are required.'));
@@ -788,13 +806,14 @@ module.exports = function(crowi, app) {
           throw new Error('Page exists');
           throw new Error('Page exists');
         }
         }
 
 
-        return Page.create(pagePath, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+        const options = {grant, grantUserGroupId, socketClientId};
+        return Page.create(pagePath, body, req.user, options);
       })
       })
       .catch(function(err) {
       .catch(function(err) {
         return res.json(ApiResponse.error(err));
         return res.json(ApiResponse.error(err));
       });
       });
 
 
-    const result = { page: createdPage.toObject() };
+    const result = { page: serializeToObj(createdPage) };
     result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
     result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
@@ -833,8 +852,10 @@ module.exports = function(crowi, app) {
     const revisionId = req.body.revision_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
+    const isSlackEnabled = !!req.body.isSlackEnabled;                     // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const slackChannels = req.body.slackChannels || null;
+    const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd;     // cast to boolean
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     if (pageId === null || pageBody === null) {
     if (pageId === null || pageBody === null) {
       return res.json(ApiResponse.error('page_id and body are required.'));
       return res.json(ApiResponse.error('page_id and body are required.'));
@@ -844,28 +865,28 @@ module.exports = function(crowi, app) {
     let updatedPage = await Page.findPageByIdAndGrantedUser(pageId, req.user)
     let updatedPage = await Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
       .then(function(pageData) {
         if (pageData && revisionId !== null && !pageData.isUpdatable(revisionId)) {
         if (pageData && revisionId !== null && !pageData.isUpdatable(revisionId)) {
-          throw new Error('Revision error.');
+          throw new Error('Posted param "revisionId" is outdated.');
         }
         }
 
 
-        const grantOption = {};
+        const options = {isSyncRevisionToHackmd, socketClientId};
         if (grant != null) {
         if (grant != null) {
-          grantOption.grant = grant;
+          options.grant = grant;
         }
         }
         if (grantUserGroupId != null) {
         if (grantUserGroupId != null) {
-          grantOption.grantUserGroupId = grantUserGroupId;
+          options.grantUserGroupId = grantUserGroupId;
         }
         }
 
 
         // store previous revision
         // store previous revision
         previousRevision = pageData.revision;
         previousRevision = pageData.revision;
 
 
-        return Page.updatePage(pageData, pageBody, req.user, grantOption);
+        return Page.updatePage(pageData, pageBody, req.user, options);
       })
       })
       .catch(function(err) {
       .catch(function(err) {
-        debug('error on _api/pages.update', err);
+        logger.error('error on _api/pages.update', err);
         res.json(ApiResponse.error(err));
         res.json(ApiResponse.error(err));
       });
       });
 
 
-    const result = { page: updatedPage.toObject() };
+    const result = { page: serializeToObj(updatedPage) };
     result.page.lastUpdateUser = User.filterToPublicFields(updatedPage.lastUpdateUser);
     result.page.lastUpdateUser = User.filterToPublicFields(updatedPage.lastUpdateUser);
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
@@ -911,7 +932,7 @@ module.exports = function(crowi, app) {
 
 
     pageFinder.then(function(pageData) {
     pageFinder.then(function(pageData) {
       const result = {};
       const result = {};
-      result.page = pageData;
+      result.page = pageData;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
 
       return res.json(ApiResponse.success(result));
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
     }).catch(function(err) {
@@ -1037,22 +1058,25 @@ module.exports = function(crowi, app) {
   api.remove = function(req, res) {
   api.remove = function(req, res) {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
     const previousRevision = req.body.revision_id || null;
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     // get completely flag
     // get completely flag
     const isCompletely = (req.body.completely != null);
     const isCompletely = (req.body.completely != null);
     // get recursively flag
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
     const isRecursively = (req.body.recursively != null);
 
 
+    const options = {socketClientId};
+
     Page.findPageByIdAndGrantedUser(pageId, req.user)
     Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
       .then(function(pageData) {
         debug('Delete page', pageData._id, pageData.path);
         debug('Delete page', pageData._id, pageData.path);
 
 
         if (isCompletely) {
         if (isCompletely) {
           if (isRecursively) {
           if (isRecursively) {
-            return Page.completelyDeletePageRecursively(pageData, req.user);
+            return Page.completelyDeletePageRecursively(pageData, req.user, options);
           }
           }
           else {
           else {
-            return Page.completelyDeletePage(pageData, req.user);
+            return Page.completelyDeletePage(pageData, req.user, options);
           }
           }
         }
         }
 
 
@@ -1063,16 +1087,16 @@ module.exports = function(crowi, app) {
         }
         }
 
 
         if (isRecursively) {
         if (isRecursively) {
-          return Page.deletePageRecursively(pageData, req.user);
+          return Page.deletePageRecursively(pageData, req.user, options);
         }
         }
         else {
         else {
-          return Page.deletePage(pageData, req.user);
+          return Page.deletePage(pageData, req.user, options);
         }
         }
       })
       })
       .then(function(data) {
       .then(function(data) {
         debug('Page deleted', data.path);
         debug('Page deleted', data.path);
         const result = {};
         const result = {};
-        result.page = data;
+        result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
 
         res.json(ApiResponse.success(result));
         res.json(ApiResponse.success(result));
         return data;
         return data;
@@ -1082,7 +1106,7 @@ module.exports = function(crowi, app) {
         return globalNotificationService.notifyPageDelete(page);
         return globalNotificationService.notifyPageDelete(page);
       })
       })
       .catch(function(err) {
       .catch(function(err) {
-        debug('Error occured while get setting', err, err.stack);
+        logger.error('Error occured while get setting', err, err.stack);
         return res.json(ApiResponse.error('Failed to delete page.'));
         return res.json(ApiResponse.error('Failed to delete page.'));
       });
       });
   };
   };
@@ -1094,8 +1118,9 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} page_id Page Id.
    */
    */
-  api.revertRemove = function(req, res) {
+  api.revertRemove = function(req, res, options) {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     // get recursively flag
     // get recursively flag
     const isRecursively = (req.body.recursively !== undefined);
     const isRecursively = (req.body.recursively !== undefined);
@@ -1104,19 +1129,18 @@ module.exports = function(crowi, app) {
     .then(function(pageData) {
     .then(function(pageData) {
 
 
       if (isRecursively) {
       if (isRecursively) {
-        return Page.revertDeletedPageRecursively(pageData, req.user);
+        return Page.revertDeletedPageRecursively(pageData, req.user, {socketClientId});
       }
       }
       else {
       else {
-        return Page.revertDeletedPage(pageData, req.user);
+        return Page.revertDeletedPage(pageData, req.user, {socketClientId});
       }
       }
     }).then(function(data) {
     }).then(function(data) {
-      debug('Complete to revert deleted page', data.path);
       const result = {};
       const result = {};
-      result.page = data;
+      result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
 
       return res.json(ApiResponse.success(result));
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
     }).catch(function(err) {
-      debug('Error occured while get setting', err, err.stack);
+      logger.error('Error occured while get setting', err, err.stack);
       return res.json(ApiResponse.error('Failed to revert deleted page.'));
       return res.json(ApiResponse.error('Failed to revert deleted page.'));
     });
     });
   };
   };
@@ -1139,6 +1163,7 @@ module.exports = function(crowi, app) {
     const options = {
     const options = {
       createRedirectPage: req.body.create_redirect || 0,
       createRedirectPage: req.body.create_redirect || 0,
       moveUnderTrees: req.body.move_trees || 0,
       moveUnderTrees: req.body.move_trees || 0,
+      socketClientId: +req.body.socketClientId || undefined,
     };
     };
     const isRecursiveMove = req.body.move_recursively || 0;
     const isRecursiveMove = req.body.move_recursively || 0;
 
 
@@ -1170,7 +1195,7 @@ module.exports = function(crowi, app) {
       })
       })
       .then(function() {
       .then(function() {
         const result = {};
         const result = {};
-        result.page = page;
+        result.page = page;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
 
         return res.json(ApiResponse.success(result));
         return res.json(ApiResponse.success(result));
       })
       })
@@ -1227,7 +1252,7 @@ module.exports = function(crowi, app) {
     }).then(function(data) {
     }).then(function(data) {
       debug('Redirect Page deleted', data.path);
       debug('Redirect Page deleted', data.path);
       const result = {};
       const result = {};
-      result.page = data;
+      result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
 
       return res.json(ApiResponse.success(result));
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
     }).catch(function(err) {

+ 47 - 1
lib/service/passport.js

@@ -4,6 +4,7 @@ const LocalStrategy = require('passport-local').Strategy;
 const LdapStrategy = require('passport-ldapauth');
 const LdapStrategy = require('passport-ldapauth');
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
+const TwitterStrategy = require('passport-twitter').Strategy;
 
 
 /**
 /**
  * the service class of Passport
  * the service class of Passport
@@ -342,7 +343,6 @@ class PassportService {
     this.isGitHubStrategySetup = true;
     this.isGitHubStrategySetup = true;
     debug('GitHubStrategy: setup is done');
     debug('GitHubStrategy: setup is done');
   }
   }
-
   /**
   /**
    * reset GoogleStrategy
    * reset GoogleStrategy
    *
    *
@@ -354,6 +354,52 @@ class PassportService {
     this.isGitHubStrategySetup = false;
     this.isGitHubStrategySetup = false;
   }
   }
 
 
+  setupTwitterStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isTwitterStrategySetup) {
+      throw new Error('TwitterStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    //this
+    const isTwitterEnabled = Config.isEnabledPassportTwitter(config);
+
+    // when disabled
+    if (!isTwitterEnabled) {
+      return;
+    }
+
+    debug('TwitterStrategy: setting up..');
+    passport.use(new TwitterStrategy({
+      consumerKey: config.crowi['security:passport-twitter:consumerKey'] || process.env.OAUTH_TWITTER_CONSUMER_KEY,
+      consumerSecret: config.crowi['security:passport-twitter:consumerSecret'] || process.env.OAUTH_TWITTER_CONSUMER_SECRET,
+      callbackURL: config.crowi['security:passport-twitter:callbackUrl'] || process.env.OAUTH_TWITTER_CALLBACK_URI,
+      skipUserProfile: false,
+    }, function(accessToken, refreshToken, profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isTwitterStrategySetup = true;
+    debug('TwitterStrategy: setup is done');
+  }
+
+  /**
+   * reset GoogleStrategy
+   *
+   * @memberof PassportService
+   */
+  resetTwitterStrategy() {
+    debug('TwitterStrategy: reset');
+    passport.unuse('twitter');
+    this.isTwitterStrategySetup = false;
+  }
+
   /**
   /**
    * setup serializer and deserializer
    * setup serializer and deserializer
    *
    *

+ 5 - 0
lib/util/swigFunctions.js

@@ -102,6 +102,11 @@ module.exports = function(crowi, app, req, locals) {
     return locals.isEnabledPassport() && config.crowi['security:passport-github:isEnabled'];
     return locals.isEnabledPassport() && config.crowi['security:passport-github:isEnabled'];
   };
   };
 
 
+  locals.passportTwitterLoginEnabled = function() {
+    var config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-twitter:isEnabled'];
+  };
+
   locals.searchConfigured = function() {
   locals.searchConfigured = function() {
     if (crowi.getSearcher()) {
     if (crowi.getSearcher()) {
       return true;
       return true;

+ 0 - 5
lib/views/_form.html

@@ -1,8 +1,3 @@
-{% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>
-  {% parent %}
-{% endblock %}
-
 {% if req.form.errors %}
 {% if req.form.errors %}
 <div class="alert alert-danger">
 <div class="alert alert-danger">
   <ul>
   <ul>

+ 3 - 3
lib/views/admin/security.html

@@ -252,10 +252,10 @@
               <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
               <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
             </li>
             </li>
             <li class="tbd">
             <li class="tbd">
-              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
             </li>
             </li>
             <li class="tbd">
             <li class="tbd">
-              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> (TBD) Twitter</a>
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
             </li>
           </ul>
           </ul>
 
 
@@ -288,7 +288,7 @@
   </div>
   </div>
 
 
   <script>
   <script>
-    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting').each(function() {
+    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
       $(this).submit(function()
       $(this).submit(function()
       {
       {
         function showMessage(formId, msg, status) {
         function showMessage(formId, msg, status) {

+ 1 - 1
lib/views/admin/widget/passport/github.html

@@ -92,7 +92,7 @@
 <hr>
 <hr>
 <h4>
 <h4>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
-  <a href="#collapseHelpForGithubOauth" data-toggle="collapse">How to configure GitHub OAuth?</a>
+  <a href="#collapseHelpForGithubOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.github") }}</a>
 </h4>
 </h4>
 <ol id="collapseHelpForGithubOauth" class="collapse">
 <ol id="collapseHelpForGithubOauth" class="collapse">
   <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>
   <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>

+ 1 - 1
lib/views/admin/widget/passport/google-oauth.html

@@ -92,7 +92,7 @@
 <hr>
 <hr>
 <h4>
 <h4>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
-  <a href="#collapseHelpForGoogleOauth" data-toggle="collapse">How to configure Google OAuth?</a>
+  <a href="#collapseHelpForGoogleOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.google") }}</a>
 </h4>
 </h4>
 <ol id="collapseHelpForGoogleOauth" class="collapse">
 <ol id="collapseHelpForGoogleOauth" class="collapse">
   <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>
   <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>

+ 115 - 4
lib/views/admin/widget/passport/twitter.html

@@ -1,6 +1,117 @@
-<form action="" method="post" class="form-horizontal passportStrategy" id="twitterOauthSetting" role="form">
-  <fieldset>
-    <legend>Twitter OAuth {{ t("security_setting.configuration") }}</legend>
-    <p class="well">(TBD)</p>
+<form action="/_api/admin/security/passport-twitter" method="post" class="form-horizontal passportStrategy" id="twitterSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.Twitter.name") }}{{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
+  {% set isTwitterEnabled = settingForm['security:passport-twitter:isEnabled'] %}
+  
+  <div class="form-group">
+    <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isTwitterEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsTwitterEnabled}}" value="true" type="radio"
+              {% if true === isTwitterEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isTwitterEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsTwitterEnabled}}" value="false" type="radio"
+              {% if !isTwitterEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-twitter-hide-when-disabled" {%if !isTwitterEnabled %}style="display: none;"{% endif %}>
+
+
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-twitter:consumerKey]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-twitter:consumerKey]" value="{{ settingForm['security:passport-twitter:consumerKey'] || '' }}">
+        <p class="help-block">
+          <small>
+                {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CONSUMER_KEY") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-twitter:consumerSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-twitter:consumerSecret]" value="{{ settingForm['security:passport-twitter:consumerSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+             {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CONSUMER_SECRET") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-twitter:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-twitter:callbackUrl]" value="{{ settingForm['security:passport-twitter:callbackUrl'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CALLBACK_URL") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-Twitter" name="settingForm[security:passport-twitter:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-twitter:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-Twitter">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
   </fieldset>
   </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
 </form>
 </form>
+
+{# Help Section #}
+<hr>
+<h4>
+  <i class="fa fa-question-circle" aria-hidden="true"></i>
+  <a href="#collapseHelpForTwitterOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.twitter") }}</a>
+</h4>
+<ol id="collapseHelpForTwitterOauth" class="collapse">
+  <li>{{ t("security_setting.OAuth.Twitter.register_1", "https://apps.twitter.com/", "Twitter Application Management") }}</li>
+  <li>{{ t("security_setting.OAuth.Twitter.register_2") }}</li>
+  <li>{{ t("security_setting.OAuth.Twitter.register_3") }}</li>
+  <li>{{ t("security_setting.OAuth.Twitter.register_4", "https://${growi.host}/passport/twitter/callback", "${growi.host}") }}</li>
+</ol>
+
+<script>
+  $('input[name="settingForm[security:passport-twitter:isEnabled]"]').change(function() {
+      const isEnabled = ($(this).val() === "true");
+
+      if (isEnabled) {
+        $('#passport-twitter-hide-when-disabled').show(400);
+      }
+      else {
+        $('#passport-twitter-hide-when-disabled').hide(400);
+      }
+    });
+</script>
+

+ 2 - 0
lib/views/widget/not_found_content.html

@@ -45,4 +45,6 @@
     {% include '../_form.html' %}
     {% include '../_form.html' %}
 
 
   </div>
   </div>
+
+  <div id="page-status-alert"></div>
 </div>
 </div>

+ 1 - 3
lib/views/widget/page_content.html

@@ -51,7 +51,5 @@
 
 
   </div>
   </div>
 
 
-  <div id="notifPageEdited" class="myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
-    <i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span> {{ t('edited this page') }} <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> {{ t('Load latest') }}</a>
-  </div>
+  <div id="page-status-alert"></div>
 </div>
 </div>

+ 2 - 2
lib/views/widget/user_page_header.html

@@ -3,9 +3,9 @@
 
 
     <h4 id="revision-path"></h4>
     <h4 id="revision-path"></h4>
 
 
-    <div class="users-meta d-flex align-items-center">
+    <div class="users-info d-flex align-items-center">
       <img src="{{ pageUser|picture }}" class="picture img-circle">
       <img src="{{ pageUser|picture }}" class="picture img-circle">
-      <div class="m-l-30" style="flex: 1;">
+      <div class="users-meta" style="flex: 1;">
         <div class="d-flex align-items-center">
         <div class="d-flex align-items-center">
           <h1>
           <h1>
             {{ pageUser.name }}
             {{ pageUser.name }}

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.2.0-RC3",
+  "version": "3.2.0-RC6",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -102,6 +102,7 @@
     "passport-google-auth": "^1.0.2",
     "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
+    "passport-twitter": "^1.0.4",
     "rimraf": "^2.6.1",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "socket.io": "^2.0.3",

+ 146 - 12
resource/js/app.js

@@ -3,8 +3,11 @@ import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
+import io from 'socket.io-client';
+
 import i18nFactory from './i18n';
 import i18nFactory from './i18n';
 
 
+import loggerFactory from '@alias/logger';
 import Xss from '../../lib/util/xss';
 import Xss from '../../lib/util/xss';
 
 
 import Crowi from './util/Crowi';
 import Crowi from './util/Crowi';
@@ -24,6 +27,7 @@ import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
 import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
 import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment   from './components/PageAttachment';
 import PageAttachment   from './components/PageAttachment';
+import PageStatusAlert  from './components/PageStatusAlert';
 import SeenUserList     from './components/SeenUserList';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import RevisionUrl      from './components/Page/RevisionUrl';
@@ -36,6 +40,8 @@ import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 
 
 import * as entities from 'entities';
 import * as entities from 'entities';
 
 
+const logger = loggerFactory('growi:app');
+
 if (!window) {
 if (!window) {
   window = {};
   window = {};
 }
 }
@@ -43,6 +49,8 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 const i18n = i18nFactory(userlang);
 
 
+const socket = io();
+
 // setup xss library
 // setup xss library
 const xss = new Xss();
 const xss = new Xss();
 window.xss = xss;
 window.xss = xss;
@@ -86,6 +94,7 @@ crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').text
 if (isLoggedin) {
 if (isLoggedin) {
   crowi.fetchUsers();
   crowi.fetchUsers();
 }
 }
+const socketClientId = crowi.getSocketClientId();
 
 
 const crowiRenderer = new GrowiRenderer(crowi, null, {
 const crowiRenderer = new GrowiRenderer(crowi, null, {
   mode: 'page',
   mode: 'page',
@@ -170,25 +179,35 @@ const saveWithShortcutSuccessHandler = function(page) {
 
 
   pageId = page._id;
   pageId = page._id;
   pageRevisionId = page.revision._id;
   pageRevisionId = page.revision._id;
+  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
 
 
   // set page id to SavePageControls
   // set page id to SavePageControls
-  componentInstances.savePageControls.setPageId(pageId);  // TODO fix this line failed because of i18next
+  componentInstances.savePageControls.setPageId(pageId);
 
 
-  // re-render Page component
+  // Page component
   if (componentInstances.page != null) {
   if (componentInstances.page != null) {
     componentInstances.page.setMarkdown(page.revision.body);
     componentInstances.page.setMarkdown(page.revision.body);
   }
   }
-  // re-render PageEditor component
+  // PageEditor component
   if (componentInstances.pageEditor != null) {
   if (componentInstances.pageEditor != null) {
     const updateEditorValue = (editorMode !== 'builtin');
     const updateEditorValue = (editorMode !== 'builtin');
     componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
     componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
   }
   }
-  // set revision id to PageEditorByHackmd
+  // PageEditorByHackmd component
   if (componentInstances.pageEditorByHackmd != null) {
   if (componentInstances.pageEditorByHackmd != null) {
-    componentInstances.pageEditorByHackmd.setRevisionId(pageRevisionId);
-
-    const updateEditorValue = (editorMode !== 'hackmd');
-    componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, updateEditorValue);
+    // clear state of PageEditorByHackmd
+    componentInstances.pageEditorByHackmd.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
+    // reset
+    if (editorMode !== 'hackmd') {
+      componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, false);
+      componentInstances.pageEditorByHackmd.reset();
+    }
+  }
+  // PageStatusAlert component
+  const pageStatusAlert = componentInstances.pageStatusAlert;
+  // clear state of PageStatusAlert
+  if (componentInstances.pageStatusAlert != null) {
+    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
   }
   }
 };
 };
 
 
@@ -209,15 +228,25 @@ const saveWithShortcut = function(markdown) {
     // do nothing
     // do nothing
     return;
     return;
   }
   }
+
+  let revisionId = pageRevisionId;
   // get options
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
+
+  if (editorMode === 'hackmd') {
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+  }
 
 
   let promise = undefined;
   let promise = undefined;
   if (pageId == null) {
   if (pageId == null) {
     promise = crowi.createPage(pagePath, markdown, options);
     promise = crowi.createPage(pagePath, markdown, options);
   }
   }
   else {
   else {
-    promise = crowi.updatePage(pageId, pageRevisionId, markdown, options);
+    promise = crowi.updatePage(pageId, revisionId, markdown, options);
   }
   }
 
 
   promise
   promise
@@ -236,16 +265,24 @@ const saveWithSubmitButton = function() {
     // do nothing
     // do nothing
     return;
     return;
   }
   }
+
+  let revisionId = pageRevisionId;
   // get options
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
 
 
   let promise = undefined;
   let promise = undefined;
-  // get markdown
   if (editorMode === 'builtin') {
   if (editorMode === 'builtin') {
+    // get markdown
     promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
     promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
   }
   }
   else {
   else {
+    // get markdown
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
   }
   }
   // create or update
   // create or update
   if (pageId == null) {
   if (pageId == null) {
@@ -255,7 +292,7 @@ const saveWithSubmitButton = function() {
   }
   }
   else {
   else {
     promise = promise.then(markdown => {
     promise = promise.then(markdown => {
-      return crowi.updatePage(pageId, pageRevisionId, markdown, options);
+      return crowi.updatePage(pageId, revisionId, markdown, options);
     });
     });
   }
   }
 
 
@@ -341,8 +378,8 @@ if (writeCommentElem) {
     <CommentForm crowi={crowi}
     <CommentForm crowi={crowi}
       crowiOriginRenderer={crowiRenderer}
       crowiOriginRenderer={crowiRenderer}
       pageId={pageId}
       pageId={pageId}
-      revisionId={pageRevisionId}
       pagePath={pagePath}
       pagePath={pagePath}
+      revisionId={pageRevisionId}
       onPostComplete={postCompleteHandler}
       onPostComplete={postCompleteHandler}
       editorOptions={editorOptions}
       editorOptions={editorOptions}
       slackChannels = {slackChannels}/>,
       slackChannels = {slackChannels}/>,
@@ -367,6 +404,25 @@ if (pageEditorOptionsSelectorElem) {
   );
   );
 }
 }
 
 
+// render PageStatusAlert
+let pageStatusAlert = null;
+const pageStatusAlertElem = document.getElementById('page-status-alert');
+if (pageStatusAlertElem) {
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <PageStatusAlert crowi={crowi}
+          ref={(elem) => {
+            if (pageStatusAlert == null) {
+              pageStatusAlert = elem.getWrappedInstance();
+            }
+          }}
+          revisionId={pageRevisionId} revisionIdHackmdSynced={pageRevisionIdHackmdSynced} hasDraftOnHackmd={hasDraftOnHackmd} />
+    </I18nextProvider>,
+    pageStatusAlertElem
+  );
+  componentInstances.pageStatusAlert = pageStatusAlert;
+}
+
 // render for admin
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {
 if (customCssEditorElem != null) {
@@ -399,6 +455,84 @@ if (customHeaderEditorElem != null) {
   );
   );
 }
 }
 
 
+// notification from websocket
+function updatePageStatusAlert(page, user) {
+  const pageStatusAlert = componentInstances.pageStatusAlert;
+  if (pageStatusAlert != null) {
+    const revisionId = page.revision._id;
+    const revisionIdHackmdSynced = page.revisionHackmdSynced;
+    pageStatusAlert.setRevisionId(revisionId, revisionIdHackmdSynced);
+    pageStatusAlert.setLastUpdateUsername(user.name);
+  }
+}
+socket.on('page:create', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
+socket.on('page:update', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+
+  if (data.page.path == pagePath) {
+    // update PageStatusAlert
+    updatePageStatusAlert(data.page, data.user);
+    // update PageEditorByHackmd
+    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
+    if (pageEditorByHackmd != null) {
+      const page = data.page;
+      pageEditorByHackmd.setRevisionId(page.revision._id, page.revisionHackmdSynced);
+      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+  }
+});
+socket.on('page:delete', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
+socket.on('page:editingWithHackmd', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
+
+  if (data.page.path == pagePath) {
+    // update PageStatusAlert
+    const pageStatusAlert = componentInstances.pageStatusAlert;
+    if (pageStatusAlert != null) {
+      pageStatusAlert.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+    // update PageEditorByHackmd
+    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
+    if (pageEditorByHackmd != null) {
+      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+  }
+});
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));

+ 10 - 8
resource/js/components/PageComment/CommentForm.js

@@ -54,8 +54,8 @@ export default class CommentForm extends React.Component {
     this.handleSelect = this.handleSelect.bind(this);
     this.handleSelect = this.handleSelect.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onUpload = this.onUpload.bind(this);
-    this.onChannelChange = this.onChannelChange.bind(this);
-    this.onSlackOnChange = this.onSlackOnChange.bind(this);
+    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
+    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
   }
   }
 
 
   updateState(value) {
   updateState(value) {
@@ -74,11 +74,11 @@ export default class CommentForm extends React.Component {
     this.renderHtml(this.state.comment);
     this.renderHtml(this.state.comment);
   }
   }
 
 
-  onSlackOnChange(value) {
+  onSlackEnabledFlagChange(value) {
     this.setState({isSlackEnabled: value});
     this.setState({isSlackEnabled: value});
   }
   }
 
 
-  onChannelChange(value) {
+  onSlackChannelsChange(value) {
     this.setState({slackChannels: value});
     this.setState({slackChannels: value});
   }
   }
 
 
@@ -269,11 +269,13 @@ export default class CommentForm extends React.Component {
                 </div>
                 </div>
                 <div className="comment-submit">
                 <div className="comment-submit">
                   <div className="d-flex">
                   <div className="d-flex">
+                    <label style={{flex: 1}}>
                     { this.state.key == 1 &&
                     { this.state.key == 1 &&
-                      <label style={{flex: 1}}>
+                      <span>
                         <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
                         <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
-                      </label>
+                      </span>
                     }
                     }
+                    </label>
                     <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
                     <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
                     { this.state.hasSlackConfig &&
                     { this.state.hasSlackConfig &&
                       <div className="form-inline align-self-center mr-md-2">
                       <div className="form-inline align-self-center mr-md-2">
@@ -281,10 +283,10 @@ export default class CommentForm extends React.Component {
                           crowi={this.props.crowi}
                           crowi={this.props.crowi}
                           pageId={this.props.pageId}
                           pageId={this.props.pageId}
                           pagePath={this.props.pagePath}
                           pagePath={this.props.pagePath}
-                          onSlackOnChange={this.onSlackOnChange}
-                          onChannelChange={this.onChannelChange}
                           isSlackEnabled={this.state.isSlackEnabled}
                           isSlackEnabled={this.state.isSlackEnabled}
                           slackChannels={this.state.slackChannels}
                           slackChannels={this.state.slackChannels}
+                          onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                          onChannelChange={this.onSlackChannelsChange}
                         />
                         />
                       </div>
                       </div>
                     }
                     }

+ 1 - 4
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -114,9 +114,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   componentDidMount() {
   componentDidMount() {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
     this.getCodeMirror().codeMirrorEditor = this;
-
-    // initialize caret line
-    this.setCaretLine(0);
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -516,7 +513,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
                 - リスト 1<br />
                 - リスト 1<br />
                 &nbsp;&nbsp;&nbsp;&nbsp;- リスト 1_1<br />
                 &nbsp;&nbsp;&nbsp;&nbsp;- リスト 1_1<br />
                 - リスト 2<br />
                 - リスト 2<br />
-                1. 番号付きリスト 1
+                1. 番号付きリスト 1<br />
                 1. 番号付きリスト 2
                 1. 番号付きリスト 2
               </p>
               </p>
               <hr />
               <hr />

+ 0 - 5
resource/js/components/PageEditor/Editor.js

@@ -32,11 +32,6 @@ export default class Editor extends AbstractEditor {
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
   }
   }
 
 
-  componentDidMount() {
-    // initialize caret line
-    this.setCaretLine(0);
-  }
-
   getEditorSubstance() {
   getEditorSubstance() {
     return this.props.isMobile
     return this.props.isMobile
       ? this.refs.taEditor
       ? this.refs.taEditor

+ 68 - 9
resource/js/components/PageEditorByHackmd.jsx

@@ -17,7 +17,9 @@ export default class PageEditorByHackmd extends React.PureComponent {
       markdown: this.props.markdown,
       markdown: this.props.markdown,
       isInitialized: false,
       isInitialized: false,
       isInitializing: false,
       isInitializing: false,
+      initialRevisionId: this.props.revisionId,
       revisionId: this.props.revisionId,
       revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
       hasDraftOnHackmd: this.props.hasDraftOnHackmd,
       hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
     };
@@ -56,12 +58,44 @@ export default class PageEditorByHackmd extends React.PureComponent {
     }
     }
   }
   }
 
 
+  /**
+   * reset initialized status
+   */
+  reset() {
+    this.setState({ isInitialized: false });
+  }
+
+  /**
+   * clear revision status (invoked when page is updated by myself)
+   */
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
+    this.setState({
+      initialRevisionId: updatedRevisionId,
+      revisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
+      isDraftUpdatingInRealtime: false,
+    });
+  }
+
   /**
   /**
    * update revisionId of state
    * update revisionId of state
    * @param {string} revisionId
    * @param {string} revisionId
+   * @param {string} revisionIdHackmdSynced
+   */
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  getRevisionIdHackmdSynced() {
+    return this.state.revisionIdHackmdSynced;
+  }
+
+  /**
+   * update hasDraftOnHackmd of state
+   * @param {bool} hasDraftOnHackmd
    */
    */
-  setRevisionId(revisionId) {
-    this.setState({revisionId});
+  setHasDraftOnHackmd(hasDraftOnHackmd) {
+    this.setState({ hasDraftOnHackmd });
   }
   }
 
 
   getHackmdUri() {
   getHackmdUri() {
@@ -163,8 +197,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
 
 
     const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
     const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
-    const isRevisionMatch = (this.state.revisionId === this.props.revisionIdHackmdSynced);
-    const isResume = isPageExistsOnHackmd && isRevisionMatch && this.state.hasDraftOnHackmd;
+    const isResume = isPageExistsOnHackmd && this.state.hasDraftOnHackmd;
 
 
     if (this.state.isInitialized) {
     if (this.state.isInitialized) {
       return (
       return (
@@ -182,8 +215,13 @@ export default class PageEditorByHackmd extends React.PureComponent {
       );
       );
     }
     }
 
 
+    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
+    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+
     let content = undefined;
     let content = undefined;
-    // HackMD is not setup
+    /*
+     * HackMD is not setup
+     */
     if (hackmdUri == null) {
     if (hackmdUri == null) {
       content = (
       content = (
         <div>
         <div>
@@ -191,7 +229,11 @@ export default class PageEditorByHackmd extends React.PureComponent {
         </div>
         </div>
       );
       );
     }
     }
+    /*
+     * Resume to edit or discard changes
+     */
     else if (isResume) {
     else if (isResume) {
+      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
       const title = (
       const title = (
         <React.Fragment>
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
           <span className="btn-label"><i className="icon-control-end"></i></span>
@@ -201,23 +243,40 @@ export default class PageEditorByHackmd extends React.PureComponent {
         <div>
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-resume-button-container mb-3">
           <div className="text-center hackmd-resume-button-container mb-3">
-            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large" className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
+            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large"
+                className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
               <MenuItem className="text-center" onClick={() => this.discardChanges()}>
               <MenuItem className="text-center" onClick={() => this.discardChanges()}>
                 <i className="icon-control-rewind"></i> Discard changes
                 <i className="icon-control-rewind"></i> Discard changes
               </MenuItem>
               </MenuItem>
             </SplitButton>
             </SplitButton>
           </div>
           </div>
-          <p className="text-center">Click to edit from the previous continuation.</p>
+          <p className="text-center">
+            Click to edit from the previous continuation<br />
+            or <button className="btn btn-link text-danger p-0 hackmd-discard-button" onClick={() => this.discardChanges()}>Discard changes</button>.
+          </p>
+          { isHackmdDocumentOutdated &&
+            <div className="panel panel-warning mt-5">
+              <div className="panel-heading"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
+              <div className="panel-body text-center">
+                The current draft on HackMD is based on&nbsp;
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.<br />
+                <button className="btn btn-link text-danger p-0 hackmd-discard-button" onClick={() => this.discardChanges()}>Discard it</button> to start to edit with current revision.
+              </div>
+            </div>
+          }
         </div>
         </div>
       );
       );
     }
     }
+    /*
+     * Start to edit
+     */
     else {
     else {
       content = (
       content = (
         <div>
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-start-button-container mb-3">
           <div className="text-center hackmd-start-button-container mb-3">
-            <button className="btn btn-info btn-lg waves-effect waves-light" type="button"
-                onClick={() => this.startToEdit()} disabled={this.state.isInitializing}>
+            <button className="btn btn-info btn-lg waves-effect waves-light" type="button" disabled={isRevisionOutdated || this.state.isInitializing}
+                onClick={() => this.startToEdit()}>
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
               Start to edit with HackMD
               Start to edit with HackMD
             </button>
             </button>

+ 2 - 2
resource/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -63,8 +63,8 @@ export default class HackmdEditor extends React.PureComponent {
   }
   }
 
 
   notifyBodyChangesHandler(body) {
   notifyBodyChangesHandler(body) {
-    // dispatch onChange()
-    if (this.props.onChange != null) {
+    // dispatch onChange() when there is difference from 'initializationMarkdown' props
+    if (this.props.onChange != null && body !== this.props.initializationMarkdown) {
       this.props.onChange(body);
       this.props.onChange(body);
     }
     }
   }
   }

+ 143 - 0
resource/js/components/PageStatusAlert.jsx

@@ -0,0 +1,143 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageStatusAlert
+ * @extends {React.Component}
+ */
+
+class PageStatusAlert extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      initialRevisionId: this.props.revisionId,
+      revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
+      lastUpdateUsername: undefined,
+      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
+      isDraftUpdatingInRealtime: false,
+    };
+
+    this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
+    this.renderDraftExistsAlert = this.renderDraftExistsAlert.bind(this);
+    this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
+  }
+
+  /**
+   * clear status (invoked when page is updated by myself)
+   */
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
+    this.setState({
+      initialRevisionId: updatedRevisionId,
+      revisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
+      hasDraftOnHackmd: false,
+      isDraftUpdatingInRealtime: false,
+    });
+  }
+
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  setLastUpdateUsername(lastUpdateUsername) {
+    this.setState({ lastUpdateUsername });
+  }
+
+  setHasDraftOnHackmd(hasDraftOnHackmd) {
+    this.setState({
+      hasDraftOnHackmd,
+      isDraftUpdatingInRealtime: true,
+    });
+  }
+
+  renderSomeoneEditingAlert() {
+    return (
+      <div className="alert-hackmd-someone-editing myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
+        <i className="icon-fw icon-people"></i>
+        Someone editing this page on HackMD
+        &nbsp;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="#hackmd">
+          Open HackMD Editor
+        </a>
+      </div>
+    );
+  }
+
+  renderDraftExistsAlert(isRealtime) {
+    return (
+      <div className="alert-hackmd-draft-exists myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
+        <i className="icon-fw icon-pencil"></i>
+        This page has a draft on HackMD
+        &nbsp;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="#hackmd">
+          Open HackMD Editor
+        </a>
+      </div>
+    );
+  }
+
+  renderUpdatedAlert() {
+    const { t } = this.props;
+    const label1 = t('edited this page');
+    const label2 = t('Load latest');
+
+    return (
+      <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
+        <i className="icon-fw icon-bulb"></i>
+        {this.state.lastUpdateUsername} {label1}
+        &nbsp;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="javascript:location.reload();">
+          {label2}
+        </a>
+      </div>
+    );
+  }
+
+  render() {
+    let content = <React.Fragment></React.Fragment>;
+
+    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
+    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+
+    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+      content = this.renderUpdatedAlert();
+    }
+    else {
+      if (this.state.isDraftUpdatingInRealtime) {
+        content = this.renderSomeoneEditingAlert();
+      }
+      else if (this.state.hasDraftOnHackmd) {
+        content = this.renderDraftExistsAlert();
+      }
+    }
+
+    return content;
+  }
+}
+
+PageStatusAlert.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  crowi: PropTypes.object.isRequired,
+  hasDraftOnHackmd: PropTypes.bool.isRequired,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+};
+
+PageStatusAlert.defaultProps = {
+};
+
+export default translate()(PageStatusAlert);

+ 0 - 1
resource/js/components/SlackNotification.jsx

@@ -46,7 +46,6 @@ export default class SlackNotification extends React.Component {
   updateStateCheckbox(event) {
   updateStateCheckbox(event) {
     const value = event.target.checked;
     const value = event.target.checked;
     this.setState({isSlackEnabled: value});
     this.setState({isSlackEnabled: value});
-    this.props.onSlackOnChange(value);
     // dispatch event
     // dispatch event
     if (this.props.onEnabledFlagChange != null) {
     if (this.props.onEnabledFlagChange != null) {
       this.props.onEnabledFlagChange(value);
       this.props.onEnabledFlagChange(value);

+ 5 - 1
resource/js/hackmd-agent.js

@@ -65,7 +65,7 @@ function postParentToNotifyBodyChanges(body) {
   window.growi.notifyBodyChanges(body);
   window.growi.notifyBodyChanges(body);
 }
 }
 // generate debounced function
 // generate debounced function
-const debouncedPostParentToNotifyBodyChanges = debounce(1500, postParentToNotifyBodyChanges);
+const debouncedPostParentToNotifyBodyChanges = debounce(800, postParentToNotifyBodyChanges);
 
 
 /**
 /**
  * postMessage to GROWI to save with shortcut
  * postMessage to GROWI to save with shortcut
@@ -88,6 +88,10 @@ function addEventListenersToCodemirror() {
 
 
   //// change event
   //// change event
   editor.on('change', (cm, change) => {
   editor.on('change', (cm, change) => {
+    if (change.origin === 'ignoreHistory') {
+      // do nothing because this operation triggered by other user
+      return;
+    }
     debouncedPostParentToNotifyBodyChanges(cm.doc.getValue());
     debouncedPostParentToNotifyBodyChanges(cm.doc.getValue());
   });
   });
 
 

+ 0 - 14
resource/js/legacy/crowi-form.js

@@ -1,14 +0,0 @@
-// const pagePath= $('#content-main').data('path');
-
-// /**
-//  * DOM ready
-//  */
-// $(function() {
-
-//   $('#page-form').on('submit', function(e) {
-//     // avoid message
-//     // isFormChanged = false;
-//     window.crowi.clearDraft(pagePath);
-//   });
-
-// });

+ 31 - 42
resource/js/legacy/crowi.js

@@ -13,7 +13,6 @@ import { debounce } from 'throttle-debounce';
 import GrowiRenderer from '../util/GrowiRenderer';
 import GrowiRenderer from '../util/GrowiRenderer';
 import Page from '../components/Page';
 import Page from '../components/Page';
 
 
-const io = require('socket.io-client');
 const entities = require('entities');
 const entities = require('entities');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
 require('jquery.cookie');
 require('jquery.cookie');
@@ -68,8 +67,7 @@ Crowi.setCaretLineData = function(line) {
 /**
 /**
  * invoked when;
  * invoked when;
  *
  *
- * 1. window loaded
- * 2. 'shown.bs.tab' event fired
+ * 1. 'shown.bs.tab' event fired
  */
  */
 Crowi.setCaretLineAndFocusToEditor = function() {
 Crowi.setCaretLineAndFocusToEditor = function() {
   // get 'data-caret-line' attributes
   // get 'data-caret-line' attributes
@@ -79,13 +77,10 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
     return;
   }
   }
 
 
-  const line = pageEditorDom.getAttribute('data-caret-line');
-
-  if (line != null && !isNaN(line)) {
-    crowi.setCaretLine(+line);
-    // reset data-caret-line attribute
-    pageEditorDom.removeAttribute('data-caret-line');
-  }
+  const line = pageEditorDom.getAttribute('data-caret-line') || 0;
+  crowi.setCaretLine(+line);
+  // reset data-caret-line attribute
+  pageEditorDom.removeAttribute('data-caret-line');
 
 
   // focus
   // focus
   crowi.focusToEditor();
   crowi.focusToEditor();
@@ -159,6 +154,28 @@ Crowi.handleKeyCtrlSlashHandler = (event) => {
   event.preventDefault();
   event.preventDefault();
 };
 };
 
 
+Crowi.initAffix = () => {
+  const $affixContent = $('#page-header');
+  if ($affixContent.length > 0) {
+    const $affixContentContainer = $('.row.bg-title');
+    const containerHeight = $affixContentContainer.outerHeight(true);
+    $affixContent.affix({
+      offset: {
+        top: function() {
+          return $('.navbar').outerHeight(true) + containerHeight;
+        }
+      }
+    });
+    $('[data-affix-disable]').on('click', function(e) {
+      const $elm = $($(this).data('affix-disable'));
+      $(window).off('.affix');
+      $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
+      return false;
+    });
+    $affixContentContainer.css({'min-height': containerHeight});
+  }
+};
+
 Crowi.initSlimScrollForRevisionToc = () => {
 Crowi.initSlimScrollForRevisionToc = () => {
   const revisionTocElem = document.querySelector('.growi .revision-toc');
   const revisionTocElem = document.querySelector('.growi .revision-toc');
   const tocContentElem = document.querySelector('.growi .revision-toc .markdownIt-TOC');
   const tocContentElem = document.querySelector('.growi .revision-toc .markdownIt-TOC');
@@ -362,10 +379,12 @@ $(function() {
       nameValueMap[obj.name] = obj.value;
       nameValueMap[obj.name] = obj.value;
     });
     });
 
 
+    const data = $(this).serialize() + `&socketClientId=${crowi.getSocketClientId()}`;
+
     $.ajax({
     $.ajax({
       type: 'POST',
       type: 'POST',
       url: '/_api/pages.rename',
       url: '/_api/pages.rename',
-      data: $(this).serialize(),
+      data: data,
       dataType: 'json'
       dataType: 'json'
     })
     })
     .done(function(res) {
     .done(function(res) {
@@ -559,26 +578,6 @@ $(function() {
       top.location.href = `${path}#edit`;
       top.location.href = `${path}#edit`;
     });
     });
 
 
-    // header affix
-    const $affixContent = $('#page-header');
-    if ($affixContent.length > 0) {
-      const $affixContentContainer = $('.row.bg-title');
-      const containerHeight = $affixContentContainer.outerHeight(true);
-      $affixContent.affix({
-        offset: {
-          top: function() {
-            return $('.navbar').outerHeight(true) + containerHeight;
-          }
-        }
-      });
-      $('[data-affix-disable]').on('click', function(e) {
-        const $elm = $($(this).data('affix-disable'));
-        $(window).off('.affix');
-        $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
-        return false;
-      });
-    }
-
     // Like
     // Like
     const $likeButton = $('.like-button');
     const $likeButton = $('.like-button');
     const $likeCount = $('#like-count');
     const $likeCount = $('#like-count');
@@ -675,16 +674,6 @@ $(function() {
       $b.toggleClass('overlay-on');
       $b.toggleClass('overlay-on');
     });
     });
 
 
-    //
-    const me = $('body').data('me');
-    const socket = io();
-    socket.on('page edited', function(data) {
-      if (data.user._id != me
-        && data.page.path == pagePath) {
-        $('#notifPageEdited').show();
-        $('#notifPageEdited .edited-user').html(data.user.name);
-      }
-    });
   } // end if pageId
   } // end if pageId
 
 
   // tab changing handling
   // tab changing handling
@@ -805,8 +794,8 @@ window.addEventListener('load', function(e) {
 
 
   Crowi.highlightSelectedSection(location.hash);
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
-  Crowi.setCaretLineAndFocusToEditor();
   Crowi.initSlimScrollForRevisionToc();
   Crowi.initSlimScrollForRevisionToc();
+  Crowi.initAffix();
 });
 });
 
 
 window.addEventListener('hashchange', function(e) {
 window.addEventListener('hashchange', function(e) {

+ 6 - 5
resource/js/util/Crowi.js

@@ -16,7 +16,6 @@ export default class Crowi {
   constructor(context, window) {
   constructor(context, window) {
     this.context = context;
     this.context = context;
     this.config = {};
     this.config = {};
-    this.csrfToken = context.csrfToken;
 
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
     this.isMobile = /iphone|ipad|android/.test(userAgent);
@@ -25,6 +24,7 @@ export default class Crowi {
     this.location = window.location || {};
     this.location = window.location || {};
     this.document = window.document || {};
     this.document = window.document || {};
     this.localStorage = window.localStorage || {};
     this.localStorage = window.localStorage || {};
+    this.socketClientId = Math.floor(Math.random() * 100000);
     this.pageEditor = undefined;
     this.pageEditor = undefined;
 
 
     this.fetchUsers = this.fetchUsers.bind(this);
     this.fetchUsers = this.fetchUsers.bind(this);
@@ -39,6 +39,7 @@ export default class Crowi {
     // FIXME
     // FIXME
     this.me = context.me;
     this.me = context.me;
     this.isAdmin = context.isAdmin;
     this.isAdmin = context.isAdmin;
+    this.csrfToken = context.csrfToken;
 
 
     this.users = [];
     this.users = [];
     this.userByName = {};
     this.userByName = {};
@@ -56,10 +57,6 @@ export default class Crowi {
     return window.Crowi;
     return window.Crowi;
   }
   }
 
 
-  getContext() {
-    return this.context;
-  }
-
   setConfig(config) {
   setConfig(config) {
     this.config = config;
     this.config = config;
   }
   }
@@ -72,6 +69,10 @@ export default class Crowi {
     this.pageEditor = pageEditor;
     this.pageEditor = pageEditor;
   }
   }
 
 
+  getSocketClientId() {
+    return this.socketClientId;
+  }
+
   getEmojiStrategy() {
   getEmojiStrategy() {
     return emojiStrategy;
     return emojiStrategy;
   }
   }

+ 25 - 1
resource/styles/scss/_on-edit.scss

@@ -23,7 +23,7 @@ body.on-edit {
   .row.page-comments-row,
   .row.page-comments-row,
   .row.page-attachments-row,
   .row.page-attachments-row,
   .row.not-found-message-row,
   .row.not-found-message-row,
-  .users-meta,
+  .users-info,
   .user-page-content-container,
   .user-page-content-container,
   .portal-form-button,
   .portal-form-button,
   .alert-info.alert-moved,
   .alert-info.alert-moved,
@@ -53,6 +53,13 @@ body.on-edit {
     display: none;
     display: none;
   }
   }
 
 
+  // hide hackmd related alert
+  &.hackmd #page-status-alert {
+    .alert-hackmd-someone-editing, .alert-hackmd-draft-exists {
+      display: none;
+    }
+  }
+
   /*****************
   /*****************
    * Expand Editor
    * Expand Editor
    *****************/
    *****************/
@@ -181,6 +188,18 @@ body.on-edit {
     }
     }
   }
   }
 
 
+  #page-status-alert .myadmin-alert {
+    position: absolute;
+    opacity: 0.8;
+    bottom: 42px;
+    right: 3px;
+    left: 3px;
+    box-shadow: 2px 2px 5px #666;
+
+    &:hover {
+      opacity: 1;
+    }
+  }
 
 
   &.builtin-editor {
   &.builtin-editor {
 
 
@@ -290,6 +309,11 @@ body.on-edit {
         right: 0;
         right: 0;
       }
       }
     }
     }
+    .hackmd-discard-button {
+      vertical-align: unset;
+      text-decoration: underline;
+    }
+
   }
   }
 
 
 }
 }

+ 4 - 0
resource/styles/scss/_page.scss

@@ -98,6 +98,10 @@
     padding: 10px 15px;
     padding: 10px 15px;
   }
   }
 
 
+  // show PageStatusAlert in default
+  #page-status-alert .myadmin-alert {
+    display: block;
+  }
 }
 }
 
 
 .main-container .main .content-main .revision-history { // {{{
 .main-container .main .content-main .revision-history { // {{{

+ 26 - 0
resource/styles/scss/_user.scss

@@ -1,7 +1,16 @@
 .main-container {
 .main-container {
   .user-page-header {
   .user-page-header {
 
 
+    #revision-path {
+      margin-bottom: 0;
+    }
+
+    .users-meta {
+      margin-left: 30px;
+    }
+
     h1 {
     h1 {
+      margin: 0;
       font-size: 2.5em;
       font-size: 2.5em;
       color: #666;
       color: #666;
     }
     }
@@ -35,7 +44,24 @@
         padding: 8px;
         padding: 8px;
       }
       }
     }
     }
+  }
+
+  // affix
+  .user-page-header.affix {
 
 
+    .users-meta {
+      margin-left: 15px;
+    }
+
+    h1 {
+      font-size: 1.5em;
+      line-height: 30px;
+    }
+
+    .picture {
+      height: 48px;
+      width: 48px;
+    }
   }
   }
 }
 }
 
 

+ 25 - 0
yarn.lock

@@ -6227,6 +6227,14 @@ passport-local@^1.0.0:
   dependencies:
   dependencies:
     passport-strategy "1.x.x"
     passport-strategy "1.x.x"
 
 
+passport-oauth1@1.x.x:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918"
+  dependencies:
+    oauth "0.9.x"
+    passport-strategy "1.x.x"
+    utils-merge "1.x.x"
+
 passport-oauth2@1.x.x:
 passport-oauth2@1.x.x:
   version "1.4.0"
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad"
   resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad"
@@ -6240,6 +6248,13 @@ passport-strategy@1.x, passport-strategy@1.x.x, passport-strategy@^1.0.0:
   version "1.0.0"
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
   resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
 
 
+passport-twitter@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/passport-twitter/-/passport-twitter-1.0.4.tgz#01a799e1f760bf2de49f2ba5fba32282f18932d7"
+  dependencies:
+    passport-oauth1 "1.x.x"
+    xtraverse "0.1.x"
+
 passport@^0.4.0:
 passport@^0.4.0:
   version "0.4.0"
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811"
   resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811"
@@ -8968,6 +8983,10 @@ xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
   dependencies:
   dependencies:
     lodash "^4.0.0"
     lodash "^4.0.0"
 
 
+xmldom@0.1.x:
+  version "0.1.27"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
+
 xmlhttprequest-ssl@~1.5.4:
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
@@ -8996,6 +9015,12 @@ xtend@~2.1.1:
   dependencies:
   dependencies:
     object-keys "~0.4.0"
     object-keys "~0.4.0"
 
 
+xtraverse@0.1.x:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/xtraverse/-/xtraverse-0.1.0.tgz#b741bad018ef78d8a9d2e83ade007b3f7959c732"
+  dependencies:
+    xmldom "0.1.x"
+
 y18n@^3.2.0, y18n@^3.2.1:
 y18n@^3.2.0, y18n@^3.2.1:
   version "3.2.1"
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"