Просмотр исходного кода

Merge branch 'master' into feat/handsontable-data-import

utsushiiro 7 лет назад
Родитель
Сommit
59c4ed7d09
32 измененных файлов с 615 добавлено и 276 удалено
  1. 11 1
      CHANGES.md
  2. 1 0
      config/env.dev.js
  3. 6 5
      package.json
  4. 6 2
      resource/locales/en-US/translation.json
  5. 7 3
      resource/locales/ja/translation.json
  6. 4 1
      src/client/js/app.js
  7. 73 56
      src/client/js/components/PageComment/CommentForm.jsx
  8. 2 2
      src/client/js/components/PageEditor.js
  9. 6 6
      src/client/js/components/PageEditor/Editor.jsx
  10. 8 5
      src/client/js/components/PageEditor/HandsontableModal.jsx
  11. 6 1
      src/client/js/components/PageHistory.js
  12. 0 57
      src/client/js/components/PageHistory/PageRevisionList.js
  13. 111 0
      src/client/js/components/PageHistory/PageRevisionList.jsx
  14. 0 65
      src/client/js/components/PageHistory/Revision.js
  15. 113 0
      src/client/js/components/PageHistory/Revision.jsx
  16. 1 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors.scss
  17. 4 0
      src/client/styles/scss/_on-edit.scss
  18. 19 2
      src/client/styles/scss/_page.scss
  19. 3 3
      src/server/form/invited.js
  20. 3 3
      src/server/form/login.js
  21. 2 2
      src/server/form/me/password.js
  22. 3 3
      src/server/form/register.js
  23. 10 0
      src/server/models/config.js
  24. 6 2
      src/server/models/revision.js
  25. 51 13
      src/server/models/user.js
  26. 22 8
      src/server/routes/admin.js
  27. 4 0
      src/server/routes/login-passport.js
  28. 27 17
      src/server/routes/login.js
  29. 49 0
      src/server/service/file-uploader/gridfs.js
  30. 1 1
      src/server/views/admin/security.html
  31. 8 1
      src/server/views/admin/users.html
  32. 48 17
      yarn.lock

+ 11 - 1
CHANGES.md

@@ -1,10 +1,20 @@
 CHANGES
 ========
 
-## 3.2.6-RC
+## 3.2.7-RC
 
 * Feature: Import CSV/TSV/HTML table on Spreadsheet like GUI (Handsontable)
+
+## 3.2.6
+
 * Feature: Add select alignment buttons of Spreadsheet like GUI (Handsontable)
+* Fix: Login form rejects weak password
+* Fix: An error occured by uploading attachment file when the page is not exists
+    * Introduced by 2.3.5
+* Support: Upgrade libs
+    * i18next-express-middleware
+    * i18next-node-fs-backend
+    * i18next-sprintf-postprocessor
 
 ## 3.2.5
 

+ 1 - 0
config/env.dev.js

@@ -8,6 +8,7 @@ module.exports = {
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
   ],
+  // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // PUBLIC_WIKI_ONLY: true,
 };

+ 6 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.6-RC",
+  "version": "3.2.7-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -89,9 +89,9 @@
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
-    "i18next": "^11.1.1",
-    "i18next-express-middleware": "^1.1.1",
-    "i18next-node-fs-backend": "^2.0.0",
+    "i18next": "^12.0.0",
+    "i18next-express-middleware": "^1.4.1",
+    "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",
@@ -99,7 +99,8 @@
     "migrate-mongo": "^4.0.0",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
-    "mongoose": "^5.2.0",
+    "mongoose": "^5.3.1",
+    "mongoose-gridfs": "^0.5.0",
     "mongoose-paginate": "^5.0.3",
     "mongoose-unique-validator": "^2.0.2",
     "multer": "~1.4.0",

+ 6 - 2
resource/locales/en-US/translation.json

@@ -46,8 +46,10 @@
 
   "Unportalize": "Unportalize",
 
-  "View this version": "View this version",
+  "Go to this version": "View this version",
   "View diff": "View diff",
+  "No diff": "No diff",
+  "Shrink versions that have no diffs": "Shrink versions that have no diffs",
 
   "User ID": "User ID",
   "Home": "Home",
@@ -517,7 +519,9 @@
     "Deactivate account":"Deactivate account",
     "your_own":"You cannot deactivate your own account",
     "Administrator menu":"Administrator menu",
-    "cannot_remove":"You cannot remove yourself from administrator"
+    "cannot_remove":"You cannot remove yourself from administrator",
+    "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
+    "current users": "Current users:"
   },
 
   "importer_management": {

+ 7 - 3
resource/locales/ja/translation.json

@@ -44,8 +44,10 @@
 
   "Unportalize": "ポータル解除",
 
-  "View this version": "このバージョンを見る",
-  "View diff": "差分を見る",
+  "Go to this version": "このバージョンを見る",
+  "View diff": "差分を表示",
+  "No diff": "差分なし",
+  "Shrink versions that have no diffs": "差分のないバージョンをコンパクトに表示する",
 
   "User ID": "ユーザーID",
   "Home": "ホーム",
@@ -534,7 +536,9 @@
     "Deactivate account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
     "Administrator menu": "管理者メニュー",
-    "cannot_remove": "自分自身を管理者から外すことはできません"
+    "cannot_remove": "自分自身を管理者から外すことはできません",
+    "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
+    "current users": "現在のユーザー数:"
   },
 
   "importer_management": {

+ 4 - 1
src/client/js/app.js

@@ -558,5 +558,8 @@ socket.on('page:editingWithHackmd', function(data) {
 
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('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(
+    <I18nextProvider i18n={i18n}>
+      <PageHistory pageId={pageId} crowi={crowi} />
+    </I18nextProvider>, document.getElementById('revision-history'));
 });

+ 73 - 56
src/client/js/components/PageComment/CommentForm.js → src/client/js/components/PageComment/CommentForm.jsx

@@ -11,7 +11,7 @@ import * as toastr from 'toastr';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 import Editor from '../PageEditor/Editor';
-import CommentPreview from '../PageComment/CommentPreview';
+import CommentPreview from './CommentPreview';
 import SlackNotification from '../SlackNotification';
 
 /**
@@ -33,6 +33,7 @@ export default class CommentForm extends React.Component {
     const isUploadableFile = config.upload.file;
 
     this.state = {
+      isFormShown: false,
       comment: '',
       isMarkdown: true,
       html: '',
@@ -56,6 +57,7 @@ export default class CommentForm extends React.Component {
     this.onUpload = this.onUpload.bind(this);
     this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
     this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
   }
 
   updateState(value) {
@@ -210,6 +212,10 @@ export default class CommentForm extends React.Component {
     });
   }
 
+  showCommentFormBtnClickHandler() {
+    this.setState({ isFormShown: true });
+  }
+
   renderControls() {
 
   }
@@ -225,13 +231,14 @@ export default class CommentForm extends React.Component {
 
     const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
     const submitButton = (
-      <Button type="submit"bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+      <Button type="submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
         Comment
       </Button>
     );
 
     return (
       <div>
+
         <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
           { username &&
             <div className="comment-form">
@@ -241,68 +248,78 @@ export default class CommentForm extends React.Component {
                 </a>
               </div>
               <div className="comment-form-main">
-                <div className="comment-write">
-                  <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                    <Tab eventKey={1} title="Write">
-                      <Editor ref="editor"
-                        value={this.state.comment}
-                        isGfmMode={this.state.isMarkdown}
-                        editorOptions={this.props.editorOptions}
-                        lineNumbers={false}
-                        isMobile={this.props.crowi.isMobile}
-                        isUploadable={this.state.isUploadable}
-                        isUploadableFile={this.state.isUploadableFile}
-                        emojiStrategy={emojiStrategy}
-                        onChange={this.updateState}
-                        onUpload={this.onUpload}
-                        onCtrlEnter={this.postComment}
-                      />
-                    </Tab>
-                    { this.state.isMarkdown == true &&
-                    <Tab eventKey={2} title="Preview">
-                      <div className="comment-form-preview">
-                       {commentPreview}
-                      </div>
-                    </Tab>
-                    }
-                  </Tabs>
-                </div>
-                <div className="comment-submit">
-                  <div className="d-flex">
-                    <label style={{flex: 1}}>
-                    { this.state.key == 1 &&
-                      <span>
-                        <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
-                      </span>
-                    }
-                    </label>
-                    <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                    { this.state.hasSlackConfig &&
-                      <div className="form-inline align-self-center mr-md-2">
-                        <SlackNotification
-                          crowi={this.props.crowi}
-                          pageId={this.props.pageId}
-                          pagePath={this.props.pagePath}
-                          isSlackEnabled={this.state.isSlackEnabled}
-                          slackChannels={this.state.slackChannels}
-                          onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                          onChannelChange={this.onSlackChannelsChange}
+                {/* Add Comment Button */}
+                { !this.state.isFormShown &&
+                  <button className="btn btn-lg btn-link center-block" onClick={this.showCommentFormBtnClickHandler}>
+                    <i className="icon-bubble"></i> Add Comment
+                  </button>
+                }
+                {/* Editor */}
+                { this.state.isFormShown && <React.Fragment>
+                  <div className="comment-write">
+                    <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                      <Tab eventKey={1} title="Write">
+                        <Editor ref="editor"
+                          value={this.state.comment}
+                          isGfmMode={this.state.isMarkdown}
+                          editorOptions={this.props.editorOptions}
+                          lineNumbers={false}
+                          isMobile={this.props.crowi.isMobile}
+                          isUploadable={this.state.isUploadable}
+                          isUploadableFile={this.state.isUploadableFile}
+                          emojiStrategy={emojiStrategy}
+                          onChange={this.updateState}
+                          onUpload={this.onUpload}
+                          onCtrlEnter={this.postComment}
                         />
-                      </div>
-                    }
-                    <div className="hidden-xs">{submitButton}</div>
+                      </Tab>
+                      { this.state.isMarkdown == true &&
+                      <Tab eventKey={2} title="Preview">
+                        <div className="comment-form-preview">
+                        {commentPreview}
+                        </div>
+                      </Tab>
+                      }
+                    </Tabs>
                   </div>
-                  <div className="visible-xs mt-2">
-                    <div className="d-flex justify-content-end">
-                      { this.state.errorMessage && errorMessage }
-                      <div>{submitButton}</div>
+                  <div className="comment-submit">
+                    <div className="d-flex">
+                      <label style={{flex: 1}}>
+                      { this.state.key == 1 &&
+                        <span>
+                          <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
+                        </span>
+                      }
+                      </label>
+                      <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                      { this.state.hasSlackConfig &&
+                        <div className="form-inline align-self-center mr-md-2">
+                          <SlackNotification
+                            crowi={this.props.crowi}
+                            pageId={this.props.pageId}
+                            pagePath={this.props.pagePath}
+                            isSlackEnabled={this.state.isSlackEnabled}
+                            slackChannels={this.state.slackChannels}
+                            onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                            onChannelChange={this.onSlackChannelsChange}
+                          />
+                        </div>
+                      }
+                      <div className="hidden-xs">{submitButton}</div>
+                    </div>
+                    <div className="visible-xs mt-2">
+                      <div className="d-flex justify-content-end">
+                        { this.state.errorMessage && errorMessage }
+                        <div>{submitButton}</div>
+                      </div>
                     </div>
                   </div>
-                </div>
+                </React.Fragment>}
               </div>
             </div>
           }
         </form>
+
       </div>
     );
   }

+ 2 - 2
src/client/js/components/PageEditor.js

@@ -139,9 +139,9 @@ export default class PageEditor extends React.Component {
         }
         this.refs.editor.insertText(insertText);
 
-        // update page information if created
+        // when if created newly
         if (res.pageCreated) {
-          this.pageSavedHandler(res.page);
+          // do nothing
         }
       })
       .catch(this.apiErrorHandler)

+ 6 - 6
src/client/js/components/PageEditor/Editor.js → src/client/js/components/PageEditor/Editor.jsx

@@ -15,6 +15,7 @@ export default class Editor extends AbstractEditor {
     super(props);
 
     this.state = {
+      isComponentDidMount: false,
       dropzoneActive: false,
       isUploading: false,
     };
@@ -32,6 +33,10 @@ export default class Editor extends AbstractEditor {
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
   }
 
+  componentDidMount() {
+    this.setState({ isComponentDidMount: true });
+  }
+
   getEditorSubstance() {
     return this.props.isMobile
       ? this.refs.taEditor
@@ -207,11 +212,6 @@ export default class Editor extends AbstractEditor {
   }
 
   getNavbarItems() {
-    // wait for rendering CodeMirrorEditor or TextAreaEditor
-    if (this.getEditorSubstance() == null) {
-      return null;
-    }
-
     // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
     const navbarItems = [];
 
@@ -245,7 +245,7 @@ export default class Editor extends AbstractEditor {
 
           { this.state.dropzoneActive && this.renderDropzoneOverlay() }
 
-          { this.renderNavbar() }
+          { this.state.isComponentDidMount && this.renderNavbar() }
 
           {/* for PC */}
           { !isMobile &&

+ 8 - 5
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -176,6 +176,9 @@ export default class HandsontableModal extends React.PureComponent {
 
     // store column index
     this.manuallyResizedColumnIndicesSet.add(currentColumn);
+    // force re-render
+    const hotInstance = this.refs.hotTable.hotInstance;
+    hotInstance.render();
   }
 
   modifyColWidthHandler(width, column) {
@@ -295,7 +298,7 @@ export default class HandsontableModal extends React.PureComponent {
         <Modal.Body className="p-0 d-flex flex-column">
           <div className="px-4 py-3 modal-navbar">
             <Button className="m-r-20 data-import-button" onClick={this.toggleDataImportArea}>
-              Data Import<i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down' }></i>
+              (TBD) Data Import<i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down' }></i>
             </Button>
             <ButtonGroup>
               <Button onClick={() => { this.alignButtonHandler('l') }}><i className="ti-align-left"></i></Button>
@@ -308,9 +311,9 @@ export default class HandsontableModal extends React.PureComponent {
                   <FormGroup>
                     <ControlLabel>Select Data Format</ControlLabel>
                     <FormControl componentClass="select" placeholder="select">
-                      <option value="select">CSV</option>
-                      <option value="other">TSV</option>
-                      <option value="other">HTML</option>
+                      <option value="select">(TBD) CSV</option>
+                      <option value="other">(TBD) TSV</option>
+                      <option value="other">(TBD) HTML</option>
                     </FormControl>
                   </FormGroup>
                   <FormGroup>
@@ -319,7 +322,7 @@ export default class HandsontableModal extends React.PureComponent {
                   </FormGroup>
                   <div className="d-flex justify-content-end">
                     <Button bsStyle="default" onClick={this.toggleDataImportArea}>Cancel</Button>
-                    <Button bsStyle="primary">Import</Button>
+                    <Button bsStyle="primary">(TBD) Import</Button>
                   </div>
                 </form>
               </div>

+ 6 - 1
src/client/js/components/PageHistory.js

@@ -1,9 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
 
 import PageRevisionList from './PageHistory/PageRevisionList';
 
-export default class PageHistory extends React.Component {
+class PageHistory extends React.Component {
 
   constructor(props) {
     super(props);
@@ -119,6 +120,7 @@ export default class PageHistory extends React.Component {
     return (
       <div>
         <PageRevisionList
+          t={this.props.t}
           revisions={this.state.revisions}
           diffOpened={this.state.diffOpened}
           getPreviousRevision={this.getPreviousRevision}
@@ -130,6 +132,9 @@ export default class PageHistory extends React.Component {
 }
 
 PageHistory.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   pageId: PropTypes.string,
   crowi: PropTypes.object.isRequired,
 };
+
+export default translate()(PageHistory);

+ 0 - 57
src/client/js/components/PageHistory/PageRevisionList.js

@@ -1,57 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Revision     from './Revision';
-import RevisionDiff from './RevisionDiff';
-
-export default class PageRevisionList extends React.Component {
-
-  render() {
-    const revisions = this.props.revisions,
-      revisionCount = this.props.revisions.length;
-
-    const revisionList = this.props.revisions.map((revision, idx) => {
-      const revisionId = revision._id
-        , revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-
-
-      let previousRevision;
-      if (idx+1 < revisionCount) {
-        previousRevision = revisions[idx + 1];
-      }
-      else {
-        previousRevision = revision; // if it is the first revision, show full text as diff text
-      }
-
-      return (
-        <div className="revision-hisory-outer" key={'revision-history-' + revisionId}>
-          <Revision
-            revision={revision}
-            revisionDiffOpened={revisionDiffOpened}
-            onDiffOpenClicked={this.props.onDiffOpenClicked}
-            key={'revision-history-rev-' + revisionId}
-            />
-          <RevisionDiff
-            revisionDiffOpened={revisionDiffOpened}
-            currentRevision={revision}
-            previousRevision={previousRevision}
-            key={'revision-diff-' + revisionId}
-          />
-        </div>
-      );
-    });
-
-    return (
-      <div className="revision-history-list">
-        {revisionList}
-      </div>
-    );
-  }
-}
-
-PageRevisionList.propTypes = {
-  revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
-  onDiffOpenClicked: PropTypes.func.isRequired,
-};
-

+ 111 - 0
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -0,0 +1,111 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Revision     from './Revision';
+import RevisionDiff from './RevisionDiff';
+
+export default class PageRevisionList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isCompactNodiffRevisions: true,
+    };
+
+    this.cbCompactizeChangeHandler = this.cbCompactizeChangeHandler.bind(this);
+  }
+
+  cbCompactizeChangeHandler() {
+    this.setState({ isCompactNodiffRevisions: !this.state.isCompactNodiffRevisions });
+  }
+
+  /**
+   * render a row (Revision component and RevisionDiff component)
+   * @param {Revison} revision
+   * @param {Revision} previousRevision
+   * @param {boolean} hasDiff whether revision has difference to previousRevision
+   * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
+   */
+  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
+    const revisionId = revision._id;
+    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
+
+    const classNames = ['revision-history-outer'];
+    if (isContiguousNodiff) {
+      classNames.push('revision-history-outer-contiguous-nodiff');
+    }
+
+    return (
+      <div className={classNames.join(' ')} key={`revision-history-${revisionId}`}>
+        <Revision
+          t={this.props.t}
+          revision={revision}
+          revisionDiffOpened={revisionDiffOpened}
+          hasDiff={hasDiff}
+          isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
+          onDiffOpenClicked={this.props.onDiffOpenClicked}
+          key={`revision-history-rev-${revisionId}`}
+          />
+        { hasDiff &&
+          <RevisionDiff
+            revisionDiffOpened={revisionDiffOpened}
+            currentRevision={revision}
+            previousRevision={previousRevision}
+            key={`revision-deff-${revisionId}`}
+          />
+        }
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    const revisions = this.props.revisions,
+      revisionCount = this.props.revisions.length;
+
+    let hasDiffPrev;
+
+    const revisionList = this.props.revisions.map((revision, idx) => {
+      let previousRevision;
+      if (idx+1 < revisionCount) {
+        previousRevision = revisions[idx + 1];
+      }
+      else {
+        previousRevision = revision; // if it is the first revision, show full text as diff text
+      }
+
+      const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
+      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
+
+      hasDiffPrev = hasDiff;
+
+      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+    });
+
+    const classNames = ['revision-history-list'];
+    if (this.state.isCompactNodiffRevisions) {
+      classNames.push('revision-history-list-compact');
+    }
+
+    return <React.Fragment>
+      <div className='checkbox checkbox-info pull-right'>
+        <input id='cbCompactize' type='checkbox' value={true} checked={this.state.isCompactNodiffRevisions} onChange={this.cbCompactizeChangeHandler}></input>
+        <label htmlFor='cbCompactize'>{ t('Shrink versions that have no diffs') }</label>
+      </div>
+      <div className="clearfix"></div>
+      <div className={classNames.join(' ')}>
+        {revisionList}
+      </div>
+    </React.Fragment>;
+  }
+}
+
+PageRevisionList.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  revisions: PropTypes.array,
+  diffOpened: PropTypes.object,
+  onDiffOpenClicked: PropTypes.func.isRequired,
+};
+

+ 0 - 65
src/client/js/components/PageHistory/Revision.js

@@ -1,65 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserDate     from '../Common/UserDate';
-import UserPicture  from '../User/UserPicture';
-
-export default class Revision extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
-  }
-
-  componentDidMount() {
-  }
-
-  _onDiffOpenClicked() {
-    this.props.onDiffOpenClicked(this.props.revision);
-  }
-
-  render() {
-    const revision = this.props.revision;
-    const author = revision.author;
-
-    let pic = '';
-    if (typeof author === 'object') {
-      pic = <UserPicture user={author} />;
-    }
-
-    const iconClass = this.props.revisionDiffOpened ? 'caret caret-opened' : 'caret';
-    return (
-      <div className="revision-history-main d-flex">
-        <div className="m-t-5">
-          {pic}
-        </div>
-        <div className="m-l-10">
-          <div className="revision-history-author">
-            <strong>{author.username}</strong>
-          </div>
-          <div className="revision-history-meta">
-            <p>
-              <UserDate dateTime={revision.createdAt} />
-            </p>
-            <p>
-              <a className="diff-view" onClick={this._onDiffOpenClicked}>
-                <i className={iconClass}></i> View diff
-              </a>
-              <a href={'?revision=' + revision._id } className="m-l-10">
-                <i className="icon-login"></i> Go to this version
-              </a>
-            </p>
-          </div>
-        </div>
-      </div>
-    );
-  }
-}
-
-Revision.propTypes = {
-  revision: PropTypes.object,
-  revisionDiffOpened: PropTypes.bool.isRequired,
-  onDiffOpenClicked: PropTypes.func.isRequired,
-};
-

+ 113 - 0
src/client/js/components/PageHistory/Revision.jsx

@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserDate     from '../Common/UserDate';
+import UserPicture  from '../User/UserPicture';
+
+export default class Revision extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  _onDiffOpenClicked() {
+    this.props.onDiffOpenClicked(this.props.revision);
+  }
+
+  renderSimplifiedNodiff(revision) {
+    const { t } = this.props;
+
+    const author = revision.author;
+
+    let pic = '';
+    if (typeof author === 'object') {
+      pic = <UserPicture user={author} size='sm' />;
+    }
+
+    return (
+      <div className="revision-history-main revision-history-main-nodiff my-1 d-flex align-items-center">
+        <div className="picture-container">
+          {pic}
+        </div>
+        <div className="m-l-10">
+          <div className="revision-history-meta">
+            <span className="text-muted small">
+              <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
+            </span>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderFull(revision) {
+    const { t } = this.props;
+
+    const author = revision.author;
+
+    let pic = '';
+    if (typeof author === 'object') {
+      pic = <UserPicture user={author} size='lg' />;
+    }
+
+    const iconClass = this.props.revisionDiffOpened ? 'caret caret-opened' : 'caret';
+    return (
+      <div className="revision-history-main d-flex mt-3">
+        <div className="m-t-5">
+          {pic}
+        </div>
+        <div className="m-l-10">
+          <div className="revision-history-author">
+            <strong>{author.username}</strong>
+          </div>
+          <div className="revision-history-meta">
+            <p>
+              <UserDate dateTime={revision.createdAt} />
+            </p>
+            <p>
+              <span className='d-inline-block' style={{ minWidth: '90px' }}>
+                { !this.props.hasDiff &&
+                  <span className='text-muted'>{ t('No diff') }</span>
+                }
+                { this.props.hasDiff &&
+                  <a className="diff-view" onClick={this._onDiffOpenClicked}>
+                    <i className={iconClass}></i> { t('View diff') }
+                  </a>
+                }
+              </span>
+              <a href={'?revision=' + revision._id } className="m-l-10">
+                <i className="icon-login"></i> { t('Go to this version') }
+              </a>
+            </p>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const revision = this.props.revision;
+
+    if (this.props.isCompactNodiffRevisions && !this.props.hasDiff) {
+      return this.renderSimplifiedNodiff(revision);
+    }
+    else {
+      return this.renderFull(revision);
+    }
+  }
+}
+
+Revision.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  revision: PropTypes.object,
+  revisionDiffOpened: PropTypes.bool.isRequired,
+  hasDiff: PropTypes.bool.isRequired,
+  isCompactNodiffRevisions: PropTypes.bool.isRequired,
+  onDiffOpenClicked: PropTypes.func.isRequired,
+};
+

+ 1 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -309,6 +309,7 @@ body.on-edit {
     .page-editor-editor-container {
       border-right-color: $navbar-border;
       .navbar-editor {
+        border-bottom-color: $border;
         background-color: $active-nav-tabs-bgcolor;   // same color with active tab
       }
     }

+ 4 - 0
src/client/styles/scss/_on-edit.scss

@@ -166,6 +166,10 @@ body.on-edit {
         }
       }
 
+      .navbar-editor {
+        border-bottom: 1px solid transparent;
+      }
+
       // add icon on cursor
       .autoformat-markdown-table-activated .CodeMirror-cursor {
         &:after {

+ 19 - 2
src/client/styles/scss/_page.scss

@@ -85,9 +85,14 @@
 .main-container .main .content-main .revision-history { // {{{
 
   .revision-history-list {
-    .revision-hisory-outer {
+    .revision-history-outer {
+      // add border-top except of first element
+      &:not(:first-of-type) {
+        border-top: 1px solid $border;
+      }
+
       .revision-history-main {
-        .picture {
+        .picture-lg {
           width: 32px;
           height: 32px;
         }
@@ -106,6 +111,12 @@
           }
         }
       }
+      .revision-history-main-nodiff {
+        .picture-container {
+          min-width: 32px;
+          text-align: center; // centering .picture
+        }
+      }
       .revision-history-diff {
         padding-left: 40px;
       }
@@ -116,6 +127,12 @@
       list-style: none;
     }
   }
+  // compacted list
+  .revision-history-list-compact {
+    .revision-history-outer-contiguous-nodiff {
+      border-top: unset !important; // force unset border
+    }
+  }
 
   // adjust
   // this is for diff2html. hide page name from diff view

+ 3 - 3
src/server/form/invited.js

@@ -1,10 +1,10 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
-  field('invitedForm.username').required().is(/^[\da-zA-Z\-_\.]+$/),
+  field('invitedForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
   field('invitedForm.name').required(),
   field('invitedForm.password').required().is(/^[\x20-\x7F]{6,}$/)
 );

+ 3 - 3
src/server/form/login.js

@@ -1,9 +1,9 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
   field('loginForm.username').required(),
-  field('loginForm.password').required().is(/^[\x20-\x7F]{6,}$/)
+  field('loginForm.password').required()
 );

+ 2 - 2
src/server/form/me/password.js

@@ -1,7 +1,7 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
   field('mePassword.oldPassword'),

+ 3 - 3
src/server/form/register.js

@@ -1,10 +1,10 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field;
+const form = require('express-form');
+const field = form.field;
 
 module.exports = form(
-  field('registerForm.username').required().is(/^[\da-zA-Z\-_\.]+$/),
+  field('registerForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
   field('registerForm.name').required(),
   field('registerForm.email').required(),
   field('registerForm.password').required().is(/^[\x20-\x7F]{6,}$/),

+ 10 - 0
src/server/models/config.js

@@ -610,6 +610,16 @@ module.exports = function(crowi) {
     return local_config;
   };
 
+  configSchema.statics.userUpperLimit = function(crowi) {
+    const key = 'USER_UPPER_LIMIT';
+    const env = crowi.env[key];
+
+    if (undefined === crowi.env || undefined === crowi.env[key]) {
+      return 0;
+    }
+    return Number(env);
+  };
+
   /*
   configSchema.statics.isInstalled = function(config)
   {

+ 6 - 2
src/server/models/revision.js

@@ -15,7 +15,8 @@ module.exports = function(crowi) {
     }},
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
-    createdAt: { type: Date, default: Date.now }
+    createdAt: { type: Date, default: Date.now },
+    hasDiffToPrev: { type: Boolean },
   });
 
   /*
@@ -80,7 +81,7 @@ module.exports = function(crowi) {
 
   revisionSchema.statics.findRevisionIdList = function(path) {
     return this.find({path: path})
-      .select('_id author createdAt')
+      .select('_id author createdAt hasDiffToPrev')
       .sort({createdAt: -1})
       .exec();
   };
@@ -135,6 +136,9 @@ module.exports = function(crowi) {
     newRevision.format = format;
     newRevision.author = user._id;
     newRevision.createdAt = Date.now();
+    if (pageData.revision != null) {
+      newRevision.hasDiffToPrev = body !== pageData.revision.body;
+    }
 
     return newRevision;
   };

+ 51 - 13
src/server/models/user.js

@@ -223,14 +223,18 @@ module.exports = function(crowi) {
     return this.updateGoogleId(null, callback);
   };
 
-  userSchema.methods.activateInvitedUser = function(username, name, password, callback) {
+  userSchema.methods.activateInvitedUser = async function(username, name, password) {
     this.setPassword(password);
     this.name = name;
     this.username = username;
     this.status = STATUS_ACTIVE;
+
     this.save(function(err, userData) {
       userEvent.emit('activated', userData);
-      return callback(err, userData);
+      if (err) {
+        throw new Error(err);
+      }
+      return userData;
     });
   };
 
@@ -422,16 +426,16 @@ module.exports = function(crowi) {
       });
   };
 
-  userSchema.statics.findUsersWithPagination = function(options, callback) {
+  userSchema.statics.findUsersWithPagination = async function(options) {
     var sort = options.sort || {status: 1, username: 1, createdAt: 1};
 
-    this.paginate({status: { $ne: STATUS_DELETED }}, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, function(err, result) {
+    return await this.paginate({status: { $ne: STATUS_DELETED }}, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, function(err, result) {
       if (err) {
         debug('Error on pagination:', err);
-        return callback(err, null);
+        throw new Error(err);
       }
 
-      return callback(err, result);
+      return result;
     }, { sortBy: sort });
   };
 
@@ -501,16 +505,38 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.statics.isRegisterableUsername = function(username, callback) {
+  userSchema.statics.isUserCountExceedsUpperLimit = async function() {
+    const Config = crowi.model('Config');
+    const userUpperLimit = Config.userUpperLimit(crowi);
+    if (userUpperLimit === 0) {
+      return false;
+    }
+
+    const activeUsers = await this.countListByStatus(STATUS_ACTIVE);
+    if (userUpperLimit !== 0 && userUpperLimit <= activeUsers) {
+      return true;
+    }
+
+    return false;
+  };
+
+  userSchema.statics.countListByStatus = async function(status) {
+    const User = this;
+    const conditions = {status: status};
+
+    // TODO count は非推奨。mongoose のバージョンアップ後に countDocuments に変更する。
+    return User.count(conditions);
+  };
+
+  userSchema.statics.isRegisterableUsername = async function(username) {
     var User = this;
     var usernameUsable = true;
 
-    this.findOne({username: username}, function(err, userData) {
-      if (userData) {
-        usernameUsable = false;
-      }
-      return callback(usernameUsable);
-    });
+    const userData = await this.findOne({username: username});
+    if (userData) {
+      usernameUsable = false;
+    }
+    return usernameUsable;
   };
 
   userSchema.statics.isRegisterable = function(email, username, callback) {
@@ -701,6 +727,13 @@ module.exports = function(crowi) {
     const User = this
       , newUser = new User();
 
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      const err = new UserUpperLimitException();
+      return callback(err);
+    }
+
     // check email duplication because email must be unique
     const count = await this.count({ email });
     if (count > 0) {
@@ -773,6 +806,11 @@ module.exports = function(crowi) {
     return username;
   };
 
+  class UserUpperLimitException {
+    constructor() {
+      this.name = this.constructor.name;
+    }
+  }
 
   userSchema.statics.STATUS_REGISTERED  = STATUS_REGISTERED;
   userSchema.statics.STATUS_ACTIVE      = STATUS_ACTIVE;

+ 22 - 8
src/server/routes/admin.js

@@ -469,16 +469,23 @@ module.exports = function(crowi, app) {
   };
 
   actions.user = {};
-  actions.user.index = function(req, res) {
+  actions.user.index = async function(req, res) {
+    const activeUsers = await User.countListByStatus(User.STATUS_ACTIVE);
+    const Config = crowi.model('Config');
+    const userUpperLimit = Config.userUpperLimit(crowi);
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+
     var page = parseInt(req.query.page) || 1;
 
-    User.findUsersWithPagination({page: page}, function(err, result) {
-      const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+    const result = await User.findUsersWithPagination({page: page});
+    const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
 
-      return res.render('admin/users', {
-        users: result.docs,
-        pager: pager
-      });
+    return res.render('admin/users', {
+      users: result.docs,
+      pager: pager,
+      activeUsers: activeUsers,
+      userUpperLimit: userUpperLimit,
+      isUserCountExceedsUpperLimit: isUserCountExceedsUpperLimit
     });
   };
 
@@ -534,7 +541,14 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.user.activate = function(req, res) {
+  actions.user.activate = async function(req, res) {
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      req.flash('errorMessage', 'ユーザーが上限に達したため有効化できません。');
+      return res.redirect('/admin/users');
+    }
+
     var id = req.params.id;
     User.findById(id, function(err, userData) {
       userData.statusActivate(function(err, userData) {

+ 4 - 0
src/server/routes/login-passport.js

@@ -415,6 +415,10 @@ module.exports = function(crowi, app) {
           return;
         }
       }
+      else if (err.name === 'UserUpperLimitException') {
+        req.flash('warningMessage', 'Can not register more than the maximum number of users.');
+        return;
+      }
     }
   };
 

+ 27 - 17
src/server/routes/login.js

@@ -188,7 +188,12 @@ module.exports = function(crowi, app) {
 
         User.createUserByEmailAndPassword(name, username, email, password, lang, function(err, userData) {
           if (err) {
-            req.flash('registerWarningMessage', 'Failed to register.');
+            if (err.name === 'UserUpperLimitException') {
+              req.flash('registerWarningMessage', 'Can not register more than the maximum number of users.');
+            }
+            else {
+              req.flash('registerWarningMessage', 'Failed to register.');
+            }
             return res.redirect('/register');
           }
           else {
@@ -328,7 +333,7 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.invited = function(req, res) {
+  actions.invited = async function(req, res) {
     if (!req.user) {
       return res.redirect('/login');
     }
@@ -340,24 +345,29 @@ module.exports = function(crowi, app) {
       var name = invitedForm.name;
       var password = invitedForm.password;
 
-      User.isRegisterableUsername(username, function(creatable) {
-        if (creatable) {
-          user.activateInvitedUser(username, name, password, function(err, data) {
-            if (err) {
-              req.flash('warningMessage', 'アクティベートに失敗しました。');
-              return res.render('invited');
-            }
-            else {
-              return res.redirect('/');
-            }
-          });
+      // check user upper limit
+      const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+      if (isUserCountExceedsUpperLimit) {
+        req.flash('warningMessage', 'ユーザーが上限に達したためアクティベートできません。');
+        return res.redirect('/invited');
+      }
+
+      const creatable = await User.isRegisterableUsername(username);
+      if (creatable) {
+        try {
+          await user.activateInvitedUser(username, name, password);
+          return res.redirect('/');
         }
-        else {
-          req.flash('warningMessage', '利用できないユーザーIDです。');
-          debug('username', username);
+        catch (err) {
+          req.flash('warningMessage', 'アクティベートに失敗しました。');
           return res.render('invited');
         }
-      });
+      }
+      else {
+        req.flash('warningMessage', '利用できないユーザーIDです。');
+        debug('username', username);
+        return res.render('invited');
+      }
     }
     else {
       return res.render('invited', {

+ 49 - 0
src/server/service/file-uploader/gridfs.js

@@ -0,0 +1,49 @@
+// crowi-fileupload-gridFS
+
+module.exports = function(crowi) {
+  'use strict';
+
+  var debug = require('debug')('growi:service:fileUploaderLocal')
+  var mongoose = require('mongoose');
+  var path = require('path');
+  var lib = {};
+  var AttachmentFile = {};
+
+  // instantiate mongoose-gridfs
+  var gridfs = require('mongoose-gridfs')({
+    collection: 'attachments',
+    model: 'AttachmentFile',
+    mongooseConnection: mongoose.connection
+  });
+
+  // obtain a model
+  AttachmentFile = gridfs.model;
+
+  // // delete a file
+  // lib.deleteFile = async function(fileId, filePath) {
+  //   debug('File deletion: ' + fileId);
+  //   await AttachmentFile.unlinkById(fileId, function(error, unlinkedAttachment) {
+  //     if (error) {
+  //       throw new Error(error);
+  //     }
+  //   });
+  // };
+
+  // create or save a file
+  lib.uploadFile = async function(filePath, contentType, fileStream, options) {
+    debug('File uploading: ' + filePath);
+    await AttachmentFile.write({filename: filePath, contentType: contentType}, fileStream,
+      function(error, createdFile) {
+        if (error) {
+          throw new Error('Failed to upload ' + createdFile + 'to gridFS', error);
+        }
+        return createdFile._id;
+      });
+  };
+
+  lib.generateUrl = function(filePath) {
+    return path.posix.join('/uploads', filePath);
+  };
+
+  return lib;
+};

+ 1 - 1
src/server/views/admin/security.html

@@ -259,7 +259,7 @@
             <li>
               <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
             </li>
-            <li class="tbd">
+            <li>
               <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
             </li>
             <li class="tbd">

+ 8 - 1
src/server/views/admin/users.html

@@ -33,7 +33,7 @@
 
     <div class="col-md-9">
       <p>
-        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">
+        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm" {% if isUserCountExceedsUpperLimit %}disabled{% endif %}>
           {{ t("user_management.invite_users") }}
         </button>
         <a class="btn btn-default btn-outline" href="/admin/users/external-accounts">
@@ -56,6 +56,13 @@
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
       </form>
 
+      {% if isUserCountExceedsUpperLimit === true %}
+      <label>{{ t('user_management.cannot_invite_maximum_users') }}</label>
+      {% endif %}
+      {% if userUpperLimit !== 0 %}
+      <label>{{ t('user_management.current users') }}{{ activeUsers }}</label>
+      {% endif %}
+
       {% set createdUser = req.flash('createdUser') %}
       {% if createdUser.length %}
       <div class="modal fade in" id="createdUserModal">

+ 48 - 17
yarn.lock

@@ -31,6 +31,12 @@
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/@handsontable/react/-/react-2.0.0.tgz#30d9c2bd05421588a6ed1b3050b1f7dc476b35d3"
 
+"@lykmapipo/gridfs-stream@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lykmapipo/gridfs-stream/-/gridfs-stream-1.2.0.tgz#0f74826816b4f7414ae36862d67ce4849a224d91"
+  dependencies:
+    flushwritable "^1.0.0"
+
 "@sinonjs/commons@^1.0.2":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.0.2.tgz#3e0ac737781627b8844257fadc3d803997d0526e"
@@ -513,7 +519,7 @@ arrify@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
 
-asap@~2.0.3:
+asap@^2.0.0, asap@~2.0.3:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
 
@@ -2705,6 +2711,13 @@ dev-ip@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0"
 
+dezalgo@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456"
+  dependencies:
+    asap "^2.0.0"
+    wrappy "1"
+
 dicer@0.2.5:
   version "0.2.5"
   resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
@@ -3618,6 +3631,10 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.1"
     readable-stream "^2.0.4"
 
+flushwritable@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/flushwritable/-/flushwritable-1.0.0.tgz#3e328d8fde412ad47e738e3be750b4d290043498"
+
 fn-args@3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/fn-args/-/fn-args-3.0.0.tgz#df5c3805ed41ec3b38a72aabe390cf9493ec084c"
@@ -4362,15 +4379,15 @@ i18next-browser-languagedetector@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-2.2.0.tgz#5f41abe61964a56dce70102ab31c3ed5d5866edc"
 
-i18next-express-middleware@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.1.1.tgz#9204f28c8800ac3bff87fbee01945367956f349c"
+i18next-express-middleware@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.4.1.tgz#273c4a490ad688ce246815ce1288689c63fa7de1"
   dependencies:
     cookies "0.7.1"
 
-i18next-node-fs-backend@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/i18next-node-fs-backend/-/i18next-node-fs-backend-2.0.0.tgz#ea0fc2c38523bdf8da24be00b73e3ad36ce907e3"
+i18next-node-fs-backend@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/i18next-node-fs-backend/-/i18next-node-fs-backend-2.1.0.tgz#b0ad55eb8671b4dedbd21fe434fb50e964a4ece2"
   dependencies:
     js-yaml "3.12.0"
     json5 "2.0.0"
@@ -4379,9 +4396,9 @@ i18next-sprintf-postprocessor@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/i18next-sprintf-postprocessor/-/i18next-sprintf-postprocessor-0.2.2.tgz#2e409f1043579382698b6a2da70cdaa551d67ea4"
 
-i18next@^11.1.1:
-  version "11.1.1"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-11.1.1.tgz#df3a683542d7756a8aa8d6b884b61141239c394a"
+i18next@^12.0.0:
+  version "12.0.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-12.0.0.tgz#27c1494219dde0451a8d714d5bfc19bf055d51bb"
 
 iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
   version "0.4.19"
@@ -5057,9 +5074,9 @@ jws@^3.1.4:
     jwa "^1.1.4"
     safe-buffer "^5.0.1"
 
-kareem@2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.2.1.tgz#9950809415aa3cde62ab43b4f7b919d99816e015"
+kareem@2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.0.tgz#ef33c42e9024dce511eeaf440cd684f3af1fc769"
 
 keycode@^2.1.2:
   version "2.1.9"
@@ -5859,6 +5876,14 @@ mongodb@^2.0.36:
     mongodb-core "2.1.19"
     readable-stream "2.2.7"
 
+mongoose-gridfs@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/mongoose-gridfs/-/mongoose-gridfs-0.5.0.tgz#626e12ab605c2ed2a205a5953cd5aa8615f44feb"
+  dependencies:
+    "@lykmapipo/gridfs-stream" "^1.2.0"
+    lodash "^4.17.10"
+    stream-read "^1.1.2"
+
 mongoose-legacy-pluralize@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
@@ -5876,13 +5901,13 @@ mongoose-unique-validator@^2.0.2:
     lodash.foreach "^4.1.0"
     lodash.get "^4.0.2"
 
-mongoose@^5.2.0:
-  version "5.2.17"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.2.17.tgz#8baeb60a675d00da03633d679a72457dbb5b2285"
+mongoose@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.3.1.tgz#52d5bfb67788a2194e5f7a2a5c0d597e4b86fd7a"
   dependencies:
     async "2.6.1"
     bson "~1.0.5"
-    kareem "2.2.1"
+    kareem "2.3.0"
     lodash.get "4.4.2"
     mongodb "3.1.6"
     mongodb-core "3.1.5"
@@ -8606,6 +8631,12 @@ stream-http@^2.7.2:
     to-arraybuffer "^1.0.0"
     xtend "^4.0.0"
 
+stream-read@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/stream-read/-/stream-read-1.1.2.tgz#3137110d7aa80ba54e4b829c4cd33ca106b9564d"
+  dependencies:
+    dezalgo "^1.0.1"
+
 stream-shift@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"