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

Merge pull request #2375 from weseek/imprv/profile-image-cache

Imprv/profile image cache
Yuki Takei 5 лет назад
Родитель
Сommit
ce938b7036

+ 4 - 3
src/client/js/app.jsx

@@ -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 />,

+ 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;
       }

+ 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;

+ 10 - 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);
@@ -304,23 +300,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 +317,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/styles/scss/_layout_growi.scss

@@ -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;
+      }
     }
   }
 

+ 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/layout-growi/user_page.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>

+ 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>