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

Merge pull request #2397 from weseek/master

release v4.0.5
Yuki Takei 5 лет назад
Родитель
Сommit
7102962bac
50 измененных файлов с 836 добавлено и 516 удалено
  1. 41 0
      .devcontainer/Dockerfile
  2. 38 0
      .devcontainer/devcontainer.json
  3. 84 0
      .devcontainer/docker-compose.yml
  4. 1 1
      .github/workflows/release-rc.yml
  5. 1 1
      .github/workflows/release.yml
  6. 1 0
      .gitignore
  7. 0 21
      .vscode/extensions.json
  8. 14 1
      CHANGES.md
  9. 5 4
      config/env.dev.js
  10. 2 2
      package.json
  11. 4 3
      public/images/icons/editor/bold.svg
  12. 2 2
      src/client/js/components/Admin/App/AppSetting.jsx
  13. 2 2
      src/client/js/components/Navbar/PersonalDropdown.jsx
  14. 1 1
      src/client/js/components/PageHistory.jsx
  15. 1 1
      src/client/js/components/SearchTypeahead.jsx
  16. 8 14
      src/client/js/components/Sidebar.jsx
  17. 38 0
      src/client/js/components/User/LikerList.jsx
  18. 38 0
      src/client/js/components/User/SeenUserList.jsx
  19. 3 24
      src/client/js/components/User/UserPicture.jsx
  20. 2 32
      src/client/js/components/User/UserPictureList.jsx
  21. 0 15
      src/client/js/legacy/crowi.js
  22. 24 75
      src/client/js/services/AppContainer.js
  23. 29 7
      src/client/js/services/CommentContainer.js
  24. 44 7
      src/client/js/services/PageContainer.js
  25. 7 3
      src/client/js/util/reveal/plugins/markdown.js
  26. 12 0
      src/client/styles/scss/_navbar.scss
  27. 6 0
      src/client/styles/scss/_search.scss
  28. 16 0
      src/client/styles/scss/_sidebar.scss
  29. 38 33
      src/server/crowi/index.js
  30. 1 1
      src/server/models/bookmark.js
  31. 10 11
      src/server/models/page.js
  32. 0 1
      src/server/models/user-group-relation.js
  33. 29 6
      src/server/models/user.js
  34. 0 1
      src/server/routes/apiv3/user-group.js
  35. 53 1
      src/server/routes/apiv3/users.js
  36. 1 1
      src/server/routes/attachment.js
  37. 1 1
      src/server/routes/comment.js
  38. 2 1
      src/server/routes/index.js
  39. 6 2
      src/server/routes/me.js
  40. 1 2
      src/server/routes/page.js
  41. 1 1
      src/server/routes/revision.js
  42. 1 1
      src/server/routes/user.js
  43. 5 1
      src/server/service/passport.js
  44. 5 5
      src/server/views/admin/app.html
  45. 10 12
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  46. 6 2
      src/server/views/layout/layout.html
  47. 0 111
      src/server/views/modal/create_page.html
  48. 1 1
      src/server/views/widget/page_list.html
  49. 36 0
      src/server/views/widget/user_page_header.html
  50. 205 105
      yarn.lock

+ 41 - 0
.devcontainer/Dockerfile

@@ -0,0 +1,41 @@
+#-------------------------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
+#-------------------------------------------------------------------------------------------------------------
+
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-12
+
+# The node image includes a non-root user with sudo access. Use the
+# "remoteUser" property in devcontainer.json to use it. On Linux, update
+# these values to ensure the container user's UID/GID matches your local values.
+# See https://aka.ms/vscode-remote/containers/non-root-user for details.
+ARG USERNAME=node
+ARG USER_UID=1000
+ARG USER_GID=$USER_UID
+
+RUN mkdir -p /workspace/growi/node_modules
+
+# [Optional] Update UID/GID if needed
+RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
+        groupmod --gid $USER_GID $USERNAME \
+        && usermod --uid $USER_UID --gid $USER_GID $USERNAME; \
+    fi
+RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
+
+# *************************************************************
+# * Uncomment this section to use RUN instructions to install *
+# * any needed dependencies after executing "apt-get update". *
+# * See https://docs.docker.com/engine/reference/builder/#run *
+# *************************************************************
+# ENV DEBIAN_FRONTEND=noninteractive
+# RUN apt-get update \
+#    && apt-get -y install --no-install-recommends <your-package-list-here> \
+#    #
+#    # Clean up
+#    && apt-get autoremove -y \
+#    && apt-get clean -y \
+#    && rm -rf /var/lib/apt/lists/*
+# ENV DEBIAN_FRONTEND=dialog
+
+# Uncomment to default to non-root user
+# USER $USER_UID

+ 38 - 0
.devcontainer/devcontainer.json

@@ -0,0 +1,38 @@
+// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
+// https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/javascript-node-12-mongo
+// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
+{
+	"name": "GROWI-Dev",
+	"dockerComposeFile": "docker-compose.yml",
+	"service": "node",
+	"workspaceFolder": "/workspace/growi",
+
+	// Set *default* container specific settings.json values on container create.
+	"settings": {
+		"terminal.integrated.shell.linux": "/bin/bash"
+	},
+
+	// Add the IDs of extensions you want installed when the container is created.
+	"extensions": [
+		"dbaeumer.vscode-eslint",
+		"eamodio.gitlens",
+		"msjsdiag.debugger-for-chrome",
+		"firefox-devtools.vscode-firefox-debug",
+		"editorconfig.editorconfig",
+		"esbenp.prettier-vscode",
+		"shinnn.stylelint",
+		"hex-ci.stylelint-plus",
+	],
+
+	// Uncomment the next line if you want start specific services in your Docker Compose config.
+	// "runServices": [],
+
+	// Uncomment the line below if you want to keep your containers running after VS Code shuts down.
+	// "shutdownAction": "none",
+
+	// Use 'postCreateCommand' to run commands after the container is created.
+	// "postCreateCommand": "yarn install",
+
+	// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
+	"remoteUser": "node"
+}

+ 84 - 0
.devcontainer/docker-compose.yml

@@ -0,0 +1,84 @@
+#-------------------------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
+#-------------------------------------------------------------------------------------------------------------
+
+version: '3'
+services:
+  node:
+    # Uncomment the next line to use a non-root user for all processes. You can also
+    # simply use the "remoteUser" property in devcontainer.json if you just want VS Code
+    # and its sub-processes (terminals, tasks, debugging) to execute as the user. On Linux,
+    # you may need to update USER_UID and USER_GID in .devcontainer/Dockerfile to match your
+    # user if not 1000. See https://aka.ms/vscode-remote/containers/non-root for details.
+    user: node
+
+    build:
+      context: .
+      dockerfile: Dockerfile
+
+    volumes:
+      - ..:/workspace/growi:cached
+      - /workspace/growi/node_modules
+      - ../../growi-docker-compose:/workspace/growi-docker-compose:cached
+      - ../../node_modules:/workspace/node_modules:cached
+
+
+    # Overrides default command so things don't shut down after the process ends.
+    command: sleep infinity
+
+    links:
+      - mongo
+      - elasticsearch
+      - hackmd
+
+  mongo:
+    image: mongo:3.6
+    restart: unless-stopped
+    ports:
+      - 27017:27017
+    volumes:
+      - /data/db
+
+  # This container requires '../../growi-docker-compose' repository
+  #   cloned from https://github.com/weseek/growi-docker-compose.git
+  elasticsearch:
+    build:
+      context: ../../growi-docker-compose/elasticsearch
+      dockerfile: ./Dockerfile
+    restart: unless-stopped
+    ports:
+      - 9200:9200
+    environment:
+      - bootstrap.memory_lock=true
+      - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+    volumes:
+      - /usr/share/elasticsearch/data
+      - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+
+  elasticsearch-head:
+    image: tobias74/elasticsearch-head:6
+    restart: unless-stopped
+    ports:
+      - 9100:9100
+
+  # This container requires '../../growi-docker-compose' repository
+  #   cloned from https://github.com/weseek/growi-docker-compose.git
+  hackmd:
+    build:
+      context: ../../growi-docker-compose/hackmd
+    restart: unless-stopped
+    environment:
+      - GROWI_URI=http://localhost:3000
+      # define 'storage' option value
+      # see https://github.com/sequelize/cli/blob/7160d0/src/helpers/config-helper.js#L192
+      - CMD_DB_URL=sqlite://dummyhost/hackmd/sqlite/codimd.db
+      - CMD_CSP_ENABLE=false
+    ports:
+      - 3010:3000
+    volumes:
+      - /files/sqlite

+ 1 - 1
.github/workflows/release-rc.yml

@@ -31,7 +31,7 @@ jobs:
 
     - name: Build Docker Image
       run: |
-        CACHE_REF=weseek/growi-cache:3
+        CACHE_REF=weseek/growi-cache:4
         docker buildx build \
           --tag growi \
           --platform linux/amd64 \

+ 1 - 1
.github/workflows/release.yml

@@ -88,7 +88,7 @@ jobs:
 
     - name: Build Docker Image
       run: |
-        CACHE_REF=weseek/growi-cache:3${{ env.SUFFIX }}
+        CACHE_REF=weseek/growi-cache:4${{ env.SUFFIX }}
         docker buildx build \
           --tag growi${{ env.SUFFIX }} \
           --build-arg flavor=${{ matrix.flavor }} \

+ 1 - 0
.gitignore

@@ -40,3 +40,4 @@ package-lock.json
 # IDE, dev #
 .idea
 *.orig
+*.code-workspace

+ 0 - 21
.vscode/extensions.json

@@ -1,21 +0,0 @@
-{
-	// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
-	// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
-
-	// List of extensions which should be recommended for users of this workspace.
-	"recommendations": [
-    "msjsdiag.debugger-for-chrome",
-    "hbenl.vscode-firefox-debug",
-    "editorconfig.editorconfig",
-    "dbaeumer.vscode-eslint",
-    "eg2.vscode-npm-script",
-    "christian-kohler.npm-intellisense",
-    "esbenp.prettier-vscode",
-    "shinnn.stylelint",
-    "hex-ci.stylelint-plus",
-	],
-	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
-	"unwantedRecommendations": [
-    "hookyqr.beautify",
-	]
-}

+ 14 - 1
CHANGES.md

@@ -1,6 +1,19 @@
 # CHANGES
 
-## v4.0.4-RC
+## v4.0.5
+
+* Improvement: Return pre-defined session id when healthcheck
+* Improvement: Refactor caching for profile image
+* Improvement: Layout for global search help on mobile
+* Improvement: Layout for confidential notation
+* Fix: Shortcut for creating page doesn't work
+* Support: Dev in container
+* Support: Upgrade libs
+    * ldapjs
+    * node-sass
+
+
+## v4.0.4
 
 * Feature: Drawer/Dock mode selector
 * Improvement: Admin pages navigation

+ 5 - 4
config/env.dev.js

@@ -2,11 +2,12 @@ module.exports = {
   NODE_ENV: 'development',
   FILE_UPLOAD: 'mongodb',
   // MONGO_GRIDFS_TOTAL_LIMIT: 10485760,   // 10MB
-  // MATHJAX: 1,
+  MATHJAX: 1,
   // NO_CDN: true,
-  // REDIS_URI: 'http://localhost:6379',
-  ELASTICSEARCH_URI: 'http://localhost:9200/growi',
-  HACKMD_URI: 'http://localhost:3010',
+  MONGO_URI: 'mongodb://mongo:27017/growi',
+  // REDIS_URI: 'http://redis:6379',
+  ELASTICSEARCH_URI: 'http://elasticsearch:9200/growi',
+  HACKMD_URI: 'http://hackmd:3010',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.0.4-RC",
+  "version": "4.0.5-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -211,7 +211,7 @@
     "mini-css-extract-plugin": "^0.9.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
-    "node-sass": "^4.12.0",
+    "node-sass": "^4.14.1",
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",

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

+ 8 - 14
src/client/js/components/Sidebar.jsx

@@ -81,7 +81,7 @@ class Sidebar extends React.Component {
 
       // clear transition temporary
       if (this.sidebarCollapsedCached) {
-        this.clearNavigationTransitionTemporary(this.navigationElem);
+        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
       }
 
       navigationUIController.disableResize();
@@ -91,9 +91,9 @@ class Sidebar extends React.Component {
     }
     // Drawer --> Dock
     else {
-      // clear transition temporary when restore collapsed sidebar
+      // clear transition temporary
       if (this.sidebarCollapsedCached) {
-        this.clearNavigationTransitionTemporary(this.ctxNavigationElem);
+        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
       }
 
       navigationUIController.enableResize();
@@ -105,23 +105,17 @@ class Sidebar extends React.Component {
     }
   }
 
-  get navigationElem() {
-    return document.querySelector('div[data-testid="Navigation"]');
-  }
-
-  get ctxNavigationElem() {
-    return document.querySelector('div[data-testid="ContextualNavigation"]');
+  get sidebarElem() {
+    return document.querySelector('.grw-sidebar');
   }
 
-  clearNavigationTransitionTemporary(elem) {
-    const transitionCache = elem.style.transition;
-
+  addCssClassTemporary(className) {
     // clear
-    elem.style.transition = undefined;
+    this.sidebarElem.classList.add(className);
 
     // restore after 300ms
     setTimeout(() => {
-      elem.style.transition = transitionCache;
+      this.sidebarElem.classList.remove(className);
     }, 300);
   }
 

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

+ 16 - 0
src/client/styles/scss/_sidebar.scss

@@ -159,6 +159,22 @@
   }
 }
 
+// supress transition
+.grw-sidebar {
+  &.grw-sidebar-supress-transitions-to-drawer {
+    div[data-testid='Navigation'] {
+      transition: none !important;
+    }
+  }
+
+  &.grw-sidebar-supress-transitions-to-dock {
+    div[data-testid='Content'],
+    div[data-testid='ContextualNavigation'] {
+      transition: none !important;
+    }
+  }
+}
+
 .grw-sidebar-backdrop.modal-backdrop {
   z-index: $zindex-fixed - 4;
 }

+ 38 - 33
src/server/crowi/index.js

@@ -203,45 +203,50 @@ Crowi.prototype.setupDatabase = function() {
   return mongoose.connect(mongoUri, { useNewUrlParser: true });
 };
 
-Crowi.prototype.setupSessionConfig = function() {
-  const self = this;
+Crowi.prototype.setupSessionConfig = async function() {
   const session = require('express-session');
   const sessionAge = (1000 * 3600 * 24 * 30);
   const redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null;
+  const uid = require('uid-safe').sync;
+
+  // generate pre-defined uid for healthcheck
+  const healthcheckUid = uid(24);
+
+  const sessionConfig = {
+    rolling: true,
+    secret: this.env.SECRET_TOKEN || 'this is default session secret',
+    resave: false,
+    saveUninitialized: true,
+    cookie: {
+      maxAge: sessionAge,
+    },
+    genid(req) {
+      // return pre-defined uid when healthcheck
+      if (req.path === '/_api/v3/healthcheck') {
+        return healthcheckUid;
+      }
+      return uid(24);
+    },
+  };
 
-  let sessionConfig;
-
-  return new Promise(((resolve, reject) => {
-    sessionConfig = {
-      rolling: true,
-      secret: self.env.SECRET_TOKEN || 'this is default session secret',
-      resave: false,
-      saveUninitialized: true,
-      cookie: {
-        maxAge: sessionAge,
-      },
-    };
-
-    if (self.env.SESSION_NAME) {
-      sessionConfig.name = self.env.SESSION_NAME;
-    }
+  if (this.env.SESSION_NAME) {
+    sessionConfig.name = this.env.SESSION_NAME;
+  }
 
-    // use Redis for session store
-    if (redisUrl) {
-      const redis = require('redis');
-      const redisClient = redis.createClient({ url: redisUrl });
-      const RedisStore = require('connect-redis')(session);
-      sessionConfig.store = new RedisStore({ client: redisClient });
-    }
-    // use MongoDB for session store
-    else {
-      const MongoStore = require('connect-mongo')(session);
-      sessionConfig.store = new MongoStore({ mongooseConnection: mongoose.connection });
-    }
+  // use Redis for session store
+  if (redisUrl) {
+    const redis = require('redis');
+    const redisClient = redis.createClient({ url: redisUrl });
+    const RedisStore = require('connect-redis')(session);
+    sessionConfig.store = new RedisStore({ client: redisClient });
+  }
+  // use MongoDB for session store
+  else {
+    const MongoStore = require('connect-mongo')(session);
+    sessionConfig.store = new MongoStore({ mongooseConnection: mongoose.connection });
+  }
 
-    self.sessionConfig = sessionConfig;
-    resolve();
-  }));
+  this.sessionConfig = sessionConfig;
 };
 
 Crowi.prototype.setupConfigManager = async function() {

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

Разница между файлами не показана из-за своего большого размера
+ 205 - 105
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов