Jelajahi Sumber

Merge branch 'master' into support/dev-in-container

Yuki Takei 5 tahun lalu
induk
melakukan
fae6fea6ef
36 mengubah file dengan 382 tambahan dan 334 penghapusan
  1. 4 3
      public/images/icons/editor/bold.svg
  2. 2 2
      src/client/js/components/Admin/App/AppSetting.jsx
  3. 2 2
      src/client/js/components/Navbar/PersonalDropdown.jsx
  4. 1 1
      src/client/js/components/PageHistory.jsx
  5. 1 1
      src/client/js/components/SearchTypeahead.jsx
  6. 38 0
      src/client/js/components/User/LikerList.jsx
  7. 38 0
      src/client/js/components/User/SeenUserList.jsx
  8. 3 24
      src/client/js/components/User/UserPicture.jsx
  9. 2 32
      src/client/js/components/User/UserPictureList.jsx
  10. 0 15
      src/client/js/legacy/crowi.js
  11. 24 75
      src/client/js/services/AppContainer.js
  12. 29 7
      src/client/js/services/CommentContainer.js
  13. 44 7
      src/client/js/services/PageContainer.js
  14. 7 3
      src/client/js/util/reveal/plugins/markdown.js
  15. 12 0
      src/client/styles/scss/_navbar.scss
  16. 6 0
      src/client/styles/scss/_search.scss
  17. 1 1
      src/server/models/bookmark.js
  18. 10 11
      src/server/models/page.js
  19. 0 1
      src/server/models/user-group-relation.js
  20. 29 6
      src/server/models/user.js
  21. 0 1
      src/server/routes/apiv3/user-group.js
  22. 53 1
      src/server/routes/apiv3/users.js
  23. 1 1
      src/server/routes/attachment.js
  24. 1 1
      src/server/routes/comment.js
  25. 2 1
      src/server/routes/index.js
  26. 6 2
      src/server/routes/me.js
  27. 1 2
      src/server/routes/page.js
  28. 1 1
      src/server/routes/revision.js
  29. 1 1
      src/server/routes/user.js
  30. 5 1
      src/server/service/passport.js
  31. 5 5
      src/server/views/admin/app.html
  32. 10 12
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  33. 6 2
      src/server/views/layout/layout.html
  34. 0 111
      src/server/views/modal/create_page.html
  35. 1 1
      src/server/views/widget/page_list.html
  36. 36 0
      src/server/views/widget/user_page_header.html

+ 4 - 3
public/images/icons/editor/bold.svg

@@ -27,7 +27,8 @@ import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import MyDraftList from './components/MyDraftList/MyDraftList';
-import UserPictureList from './components/User/UserPictureList';
+import SeenUserList from './components/User/SeenUserList';
+import LikerList from './components/User/LikerList';
 import TableOfContents from './components/TableOfContents';
 
 import PersonalSettings from './components/Me/PersonalSettings';
@@ -92,8 +93,8 @@ if (pageContainer.state.pageId != null) {
     'page-management': <PageManagement />,
 
     'revision-toc': <TableOfContents />,
-    'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
-    'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
+    'seen-user-list': <SeenUserList />,
+    'liker-list': <LikerList />,
 
     'user-created-list': <RecentCreated />,
     'user-draft-list': <MyDraftList />,

+ 2 - 2
src/client/js/components/Admin/App/AppSetting.jsx

@@ -18,9 +18,9 @@ const PageCreateButton = (props) => {
   }
 
   return (
-    <button className="nav-link create-page border-0 bg-transparent" type="button" onClick={appContainer.openPageCreateModal}>
+    <button className="px-md-2 nav-link create-page border-0 bg-transparent" type="button" onClick={appContainer.openPageCreateModal}>
       <i className="icon-pencil mr-2"></i>
-      <span>{ t('New') }</span>
+      <span className="d-none d-lg-block">{ t('New') }</span>
     </button>
   );
 };

+ 2 - 2
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -85,8 +85,8 @@ const PersonalDropdown = (props) => {
       {/* Button */}
       {/* remove .dropdown-toggle for hide caret */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
-      <a className="nav-link waves-effect waves-light" data-toggle="dropdown">
-        <UserPicture user={user} noLink noTooltip /><span className="d-none d-sm-inline-block">&nbsp;{user.name}</span>
+      <a className="px-md-2 nav-link waves-effect waves-light" data-toggle="dropdown">
+        <UserPicture user={user} noLink noTooltip /><span className="d-none d-lg-inline-block">&nbsp;{user.name}</span>
       </a>
 
       {/* Menu */}

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

@@ -49,7 +49,7 @@ class PageHistory extends React.Component {
     const diffOpened = {};
     const lastId = rev.length - 1;
     res.revisions.forEach((revision, i) => {
-      const user = this.props.crowi.findUserById(revision.author);
+      const user = revision.author;
       if (user) {
         rev[i].author = user;
       }

+ 1 - 1
src/client/js/components/SearchTypeahead.jsx

@@ -164,7 +164,7 @@ class SearchTypeahead extends React.Component {
     return (
       <span>
         <UserPicture user={page.lastUpdateUser} size="sm" noLink />
-        <span className="ml-1"><PagePathLabel page={page} /></span>
+        <span className="ml-1 text-break text-wrap"><PagePathLabel page={page} /></span>
         <PageListMeta page={page} />
       </span>
     );

+ 38 - 0
src/client/js/components/User/LikerList.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserPictureList from './UserPictureList';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import PageContainer from '../../services/PageContainer';
+
+class LikerList extends React.Component {
+
+  render() {
+    const { pageContainer } = this.props;
+    return (
+      <div className="user-list-content text-truncate text-muted text-right">
+        <span className="text-info">
+          <span className="liker-user-count">{pageContainer.state.sumOfLikers}</span>
+          <i className="icon-fw icon-like"></i>
+        </span>
+        <span className="mr-1">
+          <UserPictureList users={pageContainer.state.likerUsers} />
+        </span>
+      </div>
+    );
+  }
+
+}
+
+LikerList.propTypes = {
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LikerListWrapper = withUnstatedContainers(LikerList, [PageContainer]);
+
+export default (LikerListWrapper);

+ 38 - 0
src/client/js/components/User/SeenUserList.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserPictureList from './UserPictureList';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import PageContainer from '../../services/PageContainer';
+
+class SeenUserList extends React.Component {
+
+  render() {
+    const { pageContainer } = this.props;
+    return (
+      <div className="user-list-content text-truncate text-muted text-right">
+        <span className="text-danger">
+          <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
+          <i className="fa fa-fw fa-paw"></i>
+        </span>
+        <span className="mr-1">
+          <UserPictureList users={pageContainer.state.seenUsers} />
+        </span>
+      </div>
+    );
+  }
+
+}
+
+SeenUserList.propTypes = {
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SeenUserListWrapper = withUnstatedContainers(SeenUserList, [PageContainer]);
+
+export default (SeenUserListWrapper);

+ 3 - 24
src/client/js/components/User/UserPicture.jsx

@@ -1,5 +1,4 @@
 import React from 'react';
-import md5 from 'md5';
 import PropTypes from 'prop-types';
 
 import { userPageRoot } from '@commons/util/path-utils';
@@ -11,28 +10,6 @@ const DEFAULT_IMAGE = '/images/icons/user.svg';
 // TODO UserComponent?
 export default class UserPicture extends React.Component {
 
-  getUserPicture(user) {
-    // gravatar
-    if (user.isGravatarEnabled === true) {
-      return this.generateGravatarSrc(user);
-    }
-    // uploaded image
-    if (user.image != null) {
-      return user.image;
-    }
-    if (user.imageAttachment != null) {
-      return user.imageAttachment.filePathProxied;
-    }
-
-    return DEFAULT_IMAGE;
-  }
-
-  generateGravatarSrc(user) {
-    const email = user.email || '';
-    const hash = md5(email.trim().toLowerCase());
-    return `https://gravatar.com/avatar/${hash}`;
-  }
-
   getClassName() {
     const className = ['rounded-circle', 'picture'];
     // size
@@ -94,10 +71,12 @@ export default class UserPicture extends React.Component {
       RootElm = this.withTooltip(RootElm);
     }
 
+    const userPictureSrc = user.imageUrlCached || DEFAULT_IMAGE;
+
     return (
       <RootElm>
         <img
-          src={this.getUserPicture(user)}
+          src={userPictureSrc}
           alt={user.username}
           className={this.getClassName()}
         />

+ 2 - 32
src/client/js/components/User/UserPictureList.jsx

@@ -1,31 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
-
 import UserPicture from './UserPicture';
 
-class UserPictureList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const userIds = this.props.userIds;
-
-    const users = this.props.users.concat(
-      // FIXME: user data cache
-      this.props.appContainer.findUserByIds(userIds),
-    );
-
-    this.state = {
-      users,
-    };
-
-  }
+export default class UserPictureList extends React.Component {
 
   render() {
-    return this.state.users.map(user => (
+    return this.props.users.map(user => (
       <span key={user._id}>
         <UserPicture user={user} size="xs" />
       </span>
@@ -34,21 +15,10 @@ class UserPictureList extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const UserPictureListWrapper = withUnstatedContainers(UserPictureList, [AppContainer]);
-
 UserPictureList.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  userIds: PropTypes.arrayOf(PropTypes.string),
   users: PropTypes.arrayOf(PropTypes.object),
 };
 
 UserPictureList.defaultProps = {
-  userIds: [],
   users: [],
 };
-
-export default UserPictureListWrapper;

+ 0 - 15
src/client/js/legacy/crowi.js

@@ -95,16 +95,6 @@ Crowi.handleKeyEHandler = (event) => {
   event.preventDefault();
 };
 
-Crowi.handleKeyCHandler = (event) => {
-  // ignore when dom that has 'modal in' classes exists
-  if (document.getElementsByClassName('modal in').length > 0) {
-    return;
-  }
-  // show modal to create a page
-  $('#create-page').modal();
-  event.preventDefault();
-};
-
 Crowi.handleKeyCtrlSlashHandler = (event) => {
   // show modal to create a page
   $('#shortcuts-modal').modal('toggle');
@@ -430,11 +420,6 @@ window.addEventListener('keydown', (event) => {
         Crowi.handleKeyEHandler(event);
       }
       break;
-    case 'c':
-      if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
-        Crowi.handleKeyCHandler(event);
-      }
-      break;
     case '/':
       if (event.ctrlKey || event.metaKey) {
         Crowi.handleKeyCtrlSlashHandler(event);

+ 24 - 75
src/client/js/services/AppContainer.js

@@ -78,20 +78,16 @@ export default class AppContainer extends Container {
     const userlang = body.dataset.userlang;
     this.i18n = i18nFactory(userlang);
 
-    this.users = [];
-    this.userByName = {};
-    this.userById = {};
-    this.recoverData();
-
     if (this.isLoggedin) {
-      this.fetchUsers();
+      // remove old user cache
+      this.removeOldUserCache();
     }
 
     this.containerInstances = {};
     this.componentInstances = {};
     this.rendererInstances = {};
 
-    this.fetchUsers = this.fetchUsers.bind(this);
+    this.removeOldUserCache = this.removeOldUserCache.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
     this.apiDelete = this.apiDelete.bind(this);
@@ -107,6 +103,20 @@ export default class AppContainer extends Container {
 
     this.openPageCreateModal = this.openPageCreateModal.bind(this);
     this.closePageCreateModal = this.closePageCreateModal.bind(this);
+
+    window.addEventListener('keydown', (event) => {
+      const target = event.target;
+
+      // ignore when target dom is input
+      const inputPattern = /^input|textinput|textarea$/i;
+      if (inputPattern.test(target.tagName) || target.isContentEditable) {
+        return;
+      }
+
+      if (event.key === 'c') {
+        this.setState({ isPageCreateModalShown: true });
+      }
+    });
   }
 
   /**
@@ -304,23 +314,15 @@ export default class AppContainer extends Container {
     return emojiStrategy;
   }
 
-  recoverData() {
-    const keys = [
-      'userByName',
-      'userById',
-      'users',
-    ];
+  removeOldUserCache() {
+    if (window.localStorage.userByName == null) {
+      return;
+    }
+
+    const keys = ['userByName', 'userById', 'users', 'lastFetched'];
 
     keys.forEach((key) => {
-      const keyContent = window.localStorage[key];
-      if (keyContent) {
-        try {
-          this[key] = JSON.parse(keyContent);
-        }
-        catch (e) {
-          window.localStorage.removeItem(key);
-        }
-      }
+      window.localStorage.removeItem(key);
     });
   }
 
@@ -329,59 +331,6 @@ export default class AppContainer extends Container {
     this.setState({ recentlyUpdatedPages: data.pages });
   }
 
-  fetchUsers() {
-    const interval = 1000 * 60 * 15; // 15min
-    const currentTime = new Date();
-    if (window.localStorage.lastFetched && interval > currentTime - new Date(window.localStorage.lastFetched)) {
-      return;
-    }
-
-    this.apiGet('/users.list', {})
-      .then((data) => {
-        this.users = data.users;
-        window.localStorage.users = JSON.stringify(data.users);
-
-        const userByName = {};
-        const userById = {};
-        for (let i = 0; i < data.users.length; i++) {
-          const user = data.users[i];
-          userByName[user.username] = user;
-          userById[user._id] = user;
-        }
-        this.userByName = userByName;
-        window.localStorage.userByName = JSON.stringify(userByName);
-
-        this.userById = userById;
-        window.localStorage.userById = JSON.stringify(userById);
-
-        window.localStorage.lastFetched = new Date();
-      })
-      .catch((err) => {
-        window.localStorage.removeItem('lastFetched');
-      // ignore errors
-      });
-  }
-
-  findUserById(userId) {
-    if (this.userById && this.userById[userId]) {
-      return this.userById[userId];
-    }
-
-    return null;
-  }
-
-  findUserByIds(userIds) {
-    const users = [];
-    for (const userId of userIds) {
-      const user = this.findUserById(userId);
-      if (user) {
-        users.push(user);
-      }
-    }
-
-    return users;
-  }
-
   setEditorMode(editorMode) {
     this.setState({ editorMode });
     this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object

+ 29 - 7
src/client/js/services/CommentContainer.js

@@ -34,6 +34,7 @@ export default class CommentContainer extends Container {
     };
 
     this.retrieveComments = this.retrieveComments.bind(this);
+    this.checkAndUpdateImageOfCommentAuthers = this.checkAndUpdateImageOfCommentAuthers.bind(this);
   }
 
   /**
@@ -62,16 +63,37 @@ export default class CommentContainer extends Container {
   /**
    * Load data of comments and store them in state
    */
-  retrieveComments() {
+  async retrieveComments() {
     const { pageId } = this.getPageContainer().state;
 
     // get data (desc order array)
-    return this.appContainer.apiGet('/comments.get', { page_id: pageId })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({ comments: res.comments });
-        }
-      });
+    const res = await this.appContainer.apiGet('/comments.get', { page_id: pageId });
+    if (res.ok) {
+      const comments = res.comments;
+      this.setState({ comments });
+
+      this.checkAndUpdateImageOfCommentAuthers(comments);
+    }
+  }
+
+  async checkAndUpdateImageOfCommentAuthers(comments) {
+    const noImageCacheUserIds = comments.filter((comment) => {
+      return comment.creator.imageUrlCached == null;
+    }).map((comment) => {
+      return comment.creator._id;
+    });
+
+    if (noImageCacheUserIds.length === 0) {
+      return;
+    }
+
+    try {
+      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
+    }
+    catch (err) {
+      // Error alert doesn't apear, because user don't need to notice this error.
+      logger.error(err);
+    }
   }
 
   /**

+ 44 - 7
src/client/js/services/PageContainer.js

@@ -43,8 +43,10 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
       isLiked: JSON.parse(mainContent.getAttribute('data-page-is-liked')),
-      seenUserIds: [],
-      likerUserIds: [],
+      seenUsers: [],
+      likerUsers: [],
+      sumOfSeenUsers: 0,
+      sumOfLikers: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -72,6 +74,7 @@ export default class PageContainer extends Container {
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
+    this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
 
@@ -126,19 +129,53 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-  initStateOthers() {
+  async initStateOthers() {
 
     const seenUserListElem = document.getElementById('seen-user-list');
     if (seenUserListElem != null) {
-      const userIdsStr = seenUserListElem.dataset.userIds;
-      this.state.seenUserIds = userIdsStr.split(',');
+      const { userIdsStr, sumOfSeenUsers } = seenUserListElem.dataset;
+      this.setState({ sumOfSeenUsers });
+
+      if (userIdsStr === '') {
+        return;
+      }
+
+      const { users } = await this.appContainer.apiGet('/users.list', { user_ids: userIdsStr });
+      this.setState({ seenUsers: users });
+
+      this.checkAndUpdateImageUrlCached(users);
     }
 
 
     const likerListElem = document.getElementById('liker-list');
     if (likerListElem != null) {
-      const userIdsStr = likerListElem.dataset.userIds;
-      this.state.likerUserIds = userIdsStr.split(',');
+      const { userIdsStr, sumOfLikers } = likerListElem.dataset;
+      this.setState({ sumOfLikers });
+
+      if (userIdsStr === '') {
+        return;
+      }
+
+      const { users } = await this.appContainer.apiGet('/users.list', { user_ids: userIdsStr });
+      this.setState({ likerUsers: users });
+
+      this.checkAndUpdateImageUrlCached(users);
+    }
+  }
+
+  async checkAndUpdateImageUrlCached(users) {
+    const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
+    if (noImageCacheUsers.length === 0) {
+      return;
+    }
+
+    const noImageCacheUserIds = noImageCacheUsers.map((user) => { return user.id });
+    try {
+      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
+    }
+    catch (err) {
+      // Error alert doesn't apear, because user don't need to notice this error.
+      logger.error(err);
     }
   }
 

+ 7 - 3
src/client/js/util/reveal/plugins/markdown.js

@@ -11,9 +11,13 @@
     line-height: 1.25;
     border-bottom: 1px solid transparent;
 
-    .liker-user-count,
-    .seen-user-count {
-      font-weight: bold;
+    .user-list-content {
+      direction: rtl;
+
+      .liker-user-count,
+      .seen-user-count {
+        font-weight: bold;
+      }
     }
   }
 

+ 12 - 0
src/client/styles/scss/_navbar.scss

@@ -1,4 +1,5 @@
 .grw-navbar {
+  max-height: $grw-navbar-height + $grw-navbar-border-width;
   border-top: 0;
   border-right: 0;
   border-bottom: $grw-navbar-border-width solid;
@@ -37,6 +38,17 @@
     }
   }
   .nav-item.confidential {
+    :not(i) {
+      @include variable-font-size(14px);
+    }
+
+    @include media-breakpoint-only(md) {
+      max-width: 100px;
+    }
+
+    max-width: 120px;
+    max-height: $grw-navbar-height;
+    overflow: hidden;
     background: rgba(0, 0, 0, 0.2);
   }
 

+ 6 - 0
src/client/styles/scss/_search.scss

@@ -32,6 +32,12 @@
   .rbt-menu {
     max-height: none !important;
     margin-top: 3px;
+    @extend .dropdown-menu-right;
+    @extend .dropdown-menu-md-left;
+    @include media-breakpoint-down(sm) {
+      left: auto !important;
+      width: 90vw;
+    }
 
     li a span {
       .page-path {

+ 1 - 1
src/server/models/bookmark.js

@@ -48,7 +48,7 @@ module.exports = function(crowi) {
     return Bookmark.populate(bookmarks, {
       path: 'page',
       populate: {
-        path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS, populate: User.IMAGE_POPULATION,
+        path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS,
       },
     });
   };

+ 10 - 11
src/server/models/page.js

@@ -110,14 +110,14 @@ const addSlashOfEnd = (path) => {
  * @param {string} userPublicFields string to set to select
  */
 /* eslint-disable object-curly-newline, object-property-newline */
-const populateDataToShowRevision = (page, userPublicFields, imagePopulation) => {
+const populateDataToShowRevision = (page, userPublicFields) => {
   return page
     .populate([
-      { path: 'lastUpdateUser', model: 'User', select: userPublicFields, populate: imagePopulation },
-      { path: 'creator', model: 'User', select: userPublicFields, populate: imagePopulation },
+      { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
+      { path: 'creator', model: 'User', select: userPublicFields },
       { path: 'grantedGroup', model: 'UserGroup' },
       { path: 'revision', model: 'Revision', populate: {
-        path: 'author', model: 'User', select: userPublicFields, populate: imagePopulation,
+        path: 'author', model: 'User', select: userPublicFields,
       } },
     ]);
 };
@@ -256,18 +256,17 @@ class PageQueryBuilder {
     return this;
   }
 
-  populateDataToList(userPublicFields, imagePopulation) {
+  populateDataToList(userPublicFields) {
     this.query = this.query
       .populate({
         path: 'lastUpdateUser',
         select: userPublicFields,
-        populate: imagePopulation,
       });
     return this;
   }
 
-  populateDataToShowRevision(userPublicFields, imagePopulation) {
-    this.query = populateDataToShowRevision(this.query, userPublicFields, imagePopulation);
+  populateDataToShowRevision(userPublicFields) {
+    this.query = populateDataToShowRevision(this.query, userPublicFields);
     return this;
   }
 
@@ -450,7 +449,7 @@ module.exports = function(crowi) {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION)
+    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS)
       .execPopulate();
   };
 
@@ -743,7 +742,7 @@ module.exports = function(crowi) {
     const totalCount = await builder.query.exec('count');
 
     // find
-    builder.populateDataToList(User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION);
+    builder.populateDataToList(User.USER_PUBLIC_FIELDS);
     const pages = await builder.query.exec('find');
 
     const result = {
@@ -786,7 +785,7 @@ module.exports = function(crowi) {
 
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
-    builder.populateDataToList(User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION);
+    builder.populateDataToList(User.USER_PUBLIC_FIELDS);
     const pages = await builder.query.exec('find');
 
     const result = {

+ 0 - 1
src/server/models/user-group-relation.js

@@ -92,7 +92,6 @@ class UserGroupRelation {
       .populate({
         path: 'relatedUser',
         select: User.USER_PUBLIC_FIELDS,
-        populate: User.IMAGE_POPULATION,
       })
       .exec();
   }

+ 29 - 6
src/server/models/user.js

@@ -6,6 +6,7 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const path = require('path');
 const uniqueValidator = require('mongoose-unique-validator');
+const md5 = require('md5');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const crypto = require('crypto');
@@ -16,7 +17,9 @@ module.exports = function(crowi) {
   const STATUS_SUSPENDED = 3;
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
-  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction status lang createdAt lastLoginAt admin';
+  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
+  + 'status lang createdAt lastLoginAt admin imageUrlCached';
+  /* eslint-disable no-unused-vars */
   const IMAGE_POPULATION = { path: 'imageAttachment', select: 'filePathProxied' };
 
   const LANG_EN = 'en';
@@ -38,6 +41,7 @@ module.exports = function(crowi) {
     userId: String,
     image: String,
     imageAttachment: { type: ObjectId, ref: 'Attachment' },
+    imageUrlCached: String,
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },
     googleId: String,
@@ -153,11 +157,6 @@ module.exports = function(crowi) {
     return lang;
   }
 
-  userSchema.methods.populateImage = async function() {
-    // eslint-disable-next-line no-return-await
-    return await this.populate(IMAGE_POPULATION);
-  };
-
   userSchema.methods.isPasswordSet = function() {
     if (this.password) {
       return true;
@@ -190,6 +189,7 @@ module.exports = function(crowi) {
 
   userSchema.methods.updateIsGravatarEnabled = async function(isGravatarEnabled) {
     this.isGravatarEnabled = isGravatarEnabled;
+    await this.updateImageUrlCached();
     const userData = await this.save();
     return userData;
   };
@@ -225,6 +225,7 @@ module.exports = function(crowi) {
 
   userSchema.methods.updateImage = async function(attachment) {
     this.imageAttachment = attachment;
+    await this.updateImageUrlCached();
     return this.save();
   };
 
@@ -240,9 +241,31 @@ module.exports = function(crowi) {
     }
 
     this.imageAttachment = undefined;
+    this.updateImageUrlCached();
     return this.save();
   };
 
+  userSchema.methods.updateImageUrlCached = async function() {
+    this.imageUrlCached = await this.generateImageUrlCached();
+  };
+
+  userSchema.methods.generateImageUrlCached = async function() {
+    if (this.isGravatarEnabled) {
+      const email = this.email || '';
+      const hash = md5(email.trim().toLowerCase());
+      return `https://gravatar.com/avatar/${hash}`;
+    }
+    if (this.image != null) {
+      return this.image;
+    }
+    if (this.imageAttachment != null && this.imageAttachment._id != null) {
+      const Attachment = crowi.model('Attachment');
+      const imageAttachment = await Attachment.findById(this.imageAttachment);
+      return imageAttachment.filePathProxied;
+    }
+    return '/images/icons/user.svg';
+  };
+
   userSchema.methods.updateGoogleId = function(googleId, callback) {
     this.googleId = googleId;
     this.save((err, userData) => {

+ 0 - 1
src/server/routes/apiv3/user-group.js

@@ -587,7 +587,6 @@ module.exports = (crowi) => {
         populate: {
           path: 'lastUpdateUser',
           select: User.USER_PUBLIC_FIELDS,
-          populate: User.IMAGE_POPULATION,
         },
       });
 

+ 53 - 1
src/server/routes/apiv3/users.js

@@ -182,7 +182,6 @@ module.exports = (crowi) => {
         },
         {
           sort: sortOutput,
-          populate: User.IMAGE_POPULATION,
           page,
           limit: PAGE_ITEMS,
         },
@@ -545,5 +544,58 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg + err.message, 'extenral-account-delete-failed'));
     }
   });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/update.imageUrlCache:
+   *      put:
+   *        tags: [Users]
+   *        operationId: update.imageUrlCache
+   *        summary: /users/update.imageUrlCache
+   *        description: update imageUrlCache
+   *        parameters:
+   *          - name:  userIds
+   *            in: query
+   *            description: user id list
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: success creating imageUrlCached
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: users updated with imageUrlCached
+   */
+  router.put('/update.imageUrlCache', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    try {
+      const userIds = req.body.userIds;
+      const users = await User.find({ _id: { $in: userIds } });
+      const requests = await Promise.all(users.map(async(user) => {
+        return {
+          updateOne: {
+            filter: { _id: user._id },
+            update: { $set: { imageUrlCached: await user.generateImageUrlCached() } },
+          },
+        };
+      }));
+
+      if (requests.length > 0) {
+        await User.bulkWrite(requests);
+      }
+
+      return res.apiv3({});
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
   return router;
 };

+ 1 - 1
src/server/routes/attachment.js

@@ -354,7 +354,7 @@ module.exports = function(crowi, app) {
 
     let attachments = await Attachment.find({ page: id })
       .sort({ updatedAt: 1 })
-      .populate({ path: 'creator', select: User.USER_PUBLIC_FIELDS, populate: User.IMAGE_POPULATION });
+      .populate({ path: 'creator', select: User.USER_PUBLIC_FIELDS });
 
     attachments = attachments.map((attachment) => {
       return attachment.toObject({ virtuals: true });

+ 1 - 1
src/server/routes/comment.js

@@ -128,7 +128,7 @@ module.exports = function(crowi, app) {
     }
 
     const comments = await fetcher.populate(
-      { path: 'creator', select: User.USER_PUBLIC_FIELDS, populate: User.IMAGE_POPULATION },
+      { path: 'creator', select: User.USER_PUBLIC_FIELDS },
     );
 
     res.json(ApiResponse.success({ comments }));

+ 2 - 1
src/server/routes/index.js

@@ -123,8 +123,9 @@ module.exports = function(crowi, app) {
 
   app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
-  app.get('/attachment/:pageId/:fileName'  , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
   app.get('/attachment/:id([0-9a-z]{24})'  , loginRequired, attachment.api.get);
+  app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
+  app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
   app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
 
   app.get('/_search'                 , loginRequired , search.searchPage);

+ 6 - 2
src/server/routes/me.js

@@ -100,8 +100,12 @@ module.exports = function(crowi, app) {
       });
   };
 
-  actions.index = function(req, res) {
-    return res.render('me/index');
+  actions.index = async function(req, res) {
+    const User = crowi.model('User');
+    const userData = await User.findById(req.user.id).populate({ path: 'imageAttachment', select: 'filePathProxied' });
+    const renderVars = {};
+    renderVars.user = userData;
+    return res.render('me/index', renderVars);
   };
 
   actions.externalAccounts = {};

+ 1 - 2
src/server/routes/page.js

@@ -260,8 +260,7 @@ module.exports = function(crowi, app) {
   }
 
   async function addRenderVarsForUserPage(renderVars, page, requestUser) {
-    const userData = await User.findUserByUsername(User.getUsernameByPath(page.path))
-      .populate(User.IMAGE_POPULATION);
+    const userData = await User.findUserByUsername(User.getUsernameByPath(page.path));
 
     if (userData != null) {
       renderVars.pageUser = userData;

+ 1 - 1
src/server/routes/revision.js

@@ -170,7 +170,7 @@ module.exports = function(crowi, app) {
       Page.findByIdAndViewer(pageId, req.user)
         .then((pageData) => {
           debug('Page found', pageData._id, pageData.path);
-          return Revision.findRevisionIdList(pageData.path);
+          return Revision.findRevisionList(pageData.path);
         })
         .then((revisions) => {
           return res.json(ApiResponse.success({ revisions }));

+ 1 - 1
src/server/routes/user.js

@@ -138,7 +138,7 @@ module.exports = function(crowi, app) {
 
     const data = {};
     try {
-      const users = await userFetcher.populate(User.IMAGE_POPULATION);
+      const users = await userFetcher;
       data.users = users.map((user) => {
         // omit email
         if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'

+ 5 - 1
src/server/service/passport.js

@@ -850,10 +850,14 @@ class PassportService {
     });
     passport.deserializeUser(async(id, done) => {
       try {
-        const user = await User.findById(id).populate(User.IMAGE_POPULATION);
+        const user = await User.findById(id);
         if (user == null) {
           throw new Error('user not found');
         }
+        if (user.imageUrlCached == null) {
+          await user.updateImageUrlCached();
+          await user.save();
+        }
         done(null, user);
       }
       catch (err) {

+ 5 - 5
src/server/views/admin/app.html

@@ -47,11 +47,11 @@
     {# relocate #revision-toc #}
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
       <div class="liker-and-seenusers d-flex align-items-end justify-content-end">
-        {% if page.seenUsers.length > 10 %}<span class="text-muted">..</span>{% endif %}
-        <span id="seen-user-list" class="mr-3" data-user-ids="{{ page.seenUsers|slice(-10)|default([])|join(',') }}"></span>
-        <span class="text-danger">
-          <i class="icon-fw fa fa-paw"></i><span class="seen-user-count">{{ page.seenUsers.length|default(0) }}</span>
-        </span>
+        <div
+          id="seen-user-list"
+          data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
+          data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
+        ></div>
       </div>
       <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="116">
         <div id="revision-toc-content" class="revision-toc-content"></div>

+ 10 - 12
src/server/views/layout-growi/widget/liker-and-seenusers.html

@@ -1,14 +1,12 @@
 <div class="liker-and-seenusers">
-  <div class="text-truncate text-muted text-right" style="direction: rtl;">
-    <span class="text-info">
-      <span class="liker-user-count">{{ page.liker.length|default(0) }}</span><i class="icon-fw icon-like"></i>
-    </span>
-    <span id="liker-list" class="mr-1" data-user-ids="{{ page.liker|slice(-15)|default([])|reverse|join(',') }}"></span>
-  </div>
-  <div class="text-truncate text-muted text-right" style="direction: rtl;">
-    <span class="text-danger">
-      <span class="seen-user-count">{{ page.seenUsers.length|default(0) }}</span><i class="fa fa-fw fa-paw"></i>
-    </span>
-    <span id="seen-user-list" class="mr-1" data-user-ids="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"></span>
-  </div>
+  <div
+    id="liker-list"
+    data-user-ids-str="{{ page.liker|slice(-15)|default([])|reverse|join(',') }}"
+    data-sum-of-likers="{{ page.liker.length|default(0) }}"
+  ></div>
+  <div
+    id="seen-user-list"
+    data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
+    data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
+  ></div>
 </div>

+ 6 - 2
src/server/views/layout/layout.html

@@ -105,13 +105,17 @@
       {% endif %}
 
       {% if getConfig('crowi', 'app:confidential') %}
-      <li class="nav-item confidential text-light">{{ getConfig('crowi', 'app:confidential') }}</li>
+        <li class="nav-item confidential text-light">
+          <i class="icon-info d-md-none" data-toggle="tooltip" title="{{ getConfig('crowi', 'app:confidential') }}"></i>
+          <span class="d-none d-md-inline">
+            {{ getConfig('crowi', 'app:confidential') }}
+          </span>
+        </li>
       {% endif %}
     </ul>
 
   </nav>
 
-  {% include '../modal/create_page.html' %}
   {% endblock  %} {# layout_head_nav #}
 
   {% block head_warn_breaking_changes %}{% include '../widget/alert_breaking_changes.html' %}{% endblock %}

+ 0 - 111
src/server/views/modal/create_page.html

@@ -1,111 +0,0 @@
-<!-- TODO GW-2362 remove after adjust layout -->
-<div class="modal create-page" id="create-page">
-  <div class="modal-dialog modal-lg">
-    <div class="modal-content">
-
-      <div class="modal-header bg-primary text-light">
-        <div class="modal-title">{{ t('New Page') }}</div>
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-      </div>
-
-      <div class="modal-body">
-
-        <form  id="create-page-today" role="form">
-          <div class="row form-group">
-            <fieldset class="col-12 mb-4">
-              <h3 class="grw-modal-head pb-2">{{ t("Create today's") }}</h3>
-              <div class="d-flex create-page-input-container">
-                <div class="create-page-input-row d-flex align-items-center">
-                  <span class="page-today-prefix">{{ userPageRoot(user) }}/</span>
-                  <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control text-center" value="{{ t('Memo') }}" id="" name="">
-                  <span class="page-today-suffix">/{{ now|datetz('Y/m/d') }}/</span>
-                  <input type="text" data-prefix="/{{ now|datetz('Y/m/d') }}/" class="page-today-input2 form-control" id="page-today-input2" name="" placeholder="{{ t('Input page name (optional)') }}">
-                </div>
-                <div class="create-page-button-container">
-                  <button type="submit" class="btn btn-outline-primary rounded-pill"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button>
-                </div>
-              </div>
-            </fieldset>
-          </div>
-        </form>
-
-        {% if !isTrashPage() %}
-        <form id="create-page-under-tree" role="form">
-          <div class="row form-group">
-            <fieldset class="col-12 mb-4">
-              <h3 class="grw-modal-head pb-2">{{ t('Create under') }}</h3>
-              <div class="d-flex create-page-input-container">
-                <div class="create-page-input-row d-flex align-items-center">
-                  {% if isSearchServiceConfigured() %}
-                  <div id="create-page-name-input" class="page-name-input"></div>
-                  {% else %}
-                  <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required />
-                  {% endif %}
-                </div>
-                <div class="create-page-button-container">
-                  <button type="submit" class="btn btn-outline-primary rounded-pill"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button>
-                </div>
-              </div>
-            </fieldset>
-          </div>
-        </form>
-
-        {% set templateParentPath = parentPath(path | preventXss | escape) %}
-        <div id="template-form" class="row form-group">
-          <fieldset class="col-12">
-            <h3 class="grw-modal-head pb-2">{{ t('template.modal_label.Create template under') }}<br><code>{{ templateParentPath }}</code></h3>
-            <div class="d-flex create-page-input-container">
-              <div class="create-page-input-row d-flex align-items-center">
-
-                <div id="dd-template-type" class="dropdown w-100">
-                  <a type="button" class="btn btn-secondary btn-block dropdown-toggle d-flex align-items-center justify-content-between"
-                      id="template-type" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                    {{ t('template.option_label.select') }}
-                  </a>
-                  <div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
-                    <button class="dropdown-item" type="button" data-template-type="children">
-                      {{ t('template.children.label') }} (_template)<br class="d-block d-md-none" /><small class="text-muted text-wrap">- {{ t('template.children.desc') }}</small>
-                    </button>
-                    <button class="dropdown-item" type="button" data-template-type="decentants">
-                      {{ t('template.decendants.label') }} (__template) <br class="d-block d-md-none" /><small class="text-muted">- {{ t('template.decendants.desc') }}</small>
-                    </button>
-                  </div>
-                </div>
-
-              </div>
-              <div class="create-page-button-container">
-                <a id="link-to-template" href="{{ page.path || path }}" class="btn btn-outline-primary rounded-pill disabled">
-                  <i class="icon-fw icon-doc"></i>
-                  <span id="create-template-button-link">{{ t('Edit') }}</span>
-                </a>
-              </div>
-            </div>
-          </fieldset>
-        </div>
-        {% endif %}
-
-        <script>
-          $('#dd-template-type .dropdown-item').on('click', function() {
-            const value = $(this).data('template-type');
-
-            // modify label
-            const label = (value === 'children')
-              ? '{{ t("template.children.label") }} (__template)'
-              : '{{ t("template.decendants.label") }} (__template)';
-            $('#dd-template-type .dropdown-toggle').text(label);
-
-            // modify href
-            const pageName = (value === 'children') ? '_template' : '__template';
-            const parentPath = '{{templateParentPath}}';
-            const link = parentPath + pageName + '#edit-form';
-            $('#link-to-template').attr('href', link);
-            // enable button
-            $('#link-to-template').removeClass('disabled');
-          });
-        </script>
-
-      </div><!-- /.modal-body -->
-
-    </div><!-- /.modal-content -->
-  </div><!-- /.modal-dialog -->
-</div><!-- /.modal -->

+ 1 - 1
src/server/views/widget/page_list.html

@@ -8,7 +8,7 @@
 {% endif %}
 
 <li>
-  <img src="{{ listPage.lastUpdateUser|picture }}" class="picture rounded-circle">
+  <img src="{{ listPage.lastUpdateUser.imageUrlCached }}" class="picture rounded-circle">
   <a href="{{ encodeURI(listPage.path) }}" class="text-break ml-1">
     {{ listPage.path | preventXss }}
   </a>

+ 36 - 0
src/server/views/widget/user_page_header.html

@@ -0,0 +1,36 @@
+<div class="header-wrap">
+  <header id="page-header" class="user-page-header">
+
+    <h4 id="revision-path"></h4>
+
+    <div class="users-info d-flex align-items-center">
+      <img src="{{ pageUser.imageUrlCached }}" class="picture img-circle">
+      <div class="users-meta" style="flex: 1;">
+        <div class="d-flex align-items-center">
+          <h1>
+            {{ pageUser.name }}
+          </h1>
+        </div>
+        <div class="user-page-meta">
+          <ul>
+            <li class="user-page-username"><i class="icon-user"></i> {{ pageUser.username }}</li>
+            <li class="user-page-email">
+              <i class="icon-envelope"></i>
+              {% if pageUser.isEmailPublished %}
+                {{ pageUser.email }}
+              {% else %}
+                *****
+              {% endif %}
+            </li>
+            {% if pageUser.introduction %}
+            <li class="user-page-introduction"><p>{{ pageUser.introduction|nl2br }}</p></li>
+            {% endif %}
+          </ul>
+        </div>
+      </div>
+      <div class="d-flex">
+        {% include 'header-buttons-lg.html' %}
+      </div>
+    </div>
+  </header>
+</div>