Răsfoiți Sursa

Merge pull request #828 from weseek/imprv/refactor-attachment

Imprv/refactor attachment
Yuki Takei 7 ani în urmă
părinte
comite
8d42979837
50 a modificat fișierele cu 1137 adăugiri și 1488 ștergeri
  1. 4 1
      CHANGES.md
  2. 2 1
      package.json
  3. 34 3
      src/client/js/app.js
  4. 1 1
      src/client/js/components/BookmarkButton.jsx
  5. 50 0
      src/client/js/components/Common/UserPictureList.jsx
  6. 66 0
      src/client/js/components/LikeButton.jsx
  7. 8 1
      src/client/js/components/PageAttachment.js
  8. 2 3
      src/client/js/components/PageAttachment/Attachment.js
  9. 1 1
      src/client/js/components/PageAttachment/DeleteAttachmentModal.js
  10. 1 2
      src/client/js/components/PageComment/CommentForm.jsx
  11. 2 3
      src/client/js/components/PageEditor.js
  12. 0 52
      src/client/js/components/SeenUserList.js
  13. 0 44
      src/client/js/components/SeenUserList/UserList.js
  14. 7 1
      src/client/js/components/User/UserPicture.js
  15. 0 66
      src/client/js/legacy/crowi.js
  16. 200 202
      src/client/styles/scss/_layout_crowi_sidebar.scss
  17. 9 0
      src/client/styles/scss/_layout_growi.scss
  18. 1 1
      src/client/styles/scss/_on-edit.scss
  19. 3 3
      src/client/styles/scss/_page.scss
  20. 1 1
      src/client/styles/scss/_user.scss
  21. 15 15
      src/client/styles/scss/_user_growi.scss
  22. 50 137
      src/server/models/attachment.js
  23. 2 40
      src/server/models/comment.js
  24. 33 18
      src/server/models/page.js
  25. 1 18
      src/server/models/user-group.js
  26. 61 73
      src/server/models/user.js
  27. 0 108
      src/server/routes/admin.js
  28. 262 165
      src/server/routes/attachment.js
  29. 7 3
      src/server/routes/comment.js
  30. 10 9
      src/server/routes/index.js
  31. 0 31
      src/server/routes/login.js
  32. 0 68
      src/server/routes/me.js
  33. 3 1
      src/server/routes/page.js
  34. 13 16
      src/server/routes/user.js
  35. 66 118
      src/server/service/file-uploader/aws.js
  36. 33 117
      src/server/service/file-uploader/gridfs.js
  37. 2 0
      src/server/service/file-uploader/index.js
  38. 56 42
      src/server/service/file-uploader/local.js
  39. 11 4
      src/server/service/passport.js
  40. 18 18
      src/server/util/middlewares.js
  41. 0 49
      src/server/views/admin/user-group-detail.html
  42. 0 4
      src/server/views/admin/user-groups.html
  43. 4 13
      src/server/views/layout-crowi/widget/page_side_header.html
  44. 2 1
      src/server/views/layout-growi/page.html
  45. 2 1
      src/server/views/layout-growi/page_list.html
  46. 7 1
      src/server/views/layout-growi/user_page.html
  47. 10 0
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  48. 57 25
      src/server/views/me/index.html
  49. 6 7
      src/server/views/widget/header-button-like.html
  50. 14 0
      yarn.lock

+ 4 - 1
CHANGES.md

@@ -1,7 +1,10 @@
 CHANGES
 ========
 
-## 3.3.11-RC
+## 3.4.0-RC
+
+* Improvement: Restrict to access attachments when the user is not allowed to see page
+* Fix: Profile image is not displayed when `FILE_UPLOAD=mongodb`
 
 ## 3.3.10
 

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.3.11-RC",
+  "version": "3.4.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -101,6 +101,7 @@
     "mongoose-paginate": "^5.0.3",
     "mongoose-unique-validator": "^2.0.2",
     "multer": "~1.4.0",
+    "multer-autoreap": "^1.0.3",
     "nodemailer": "^5.1.1",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",

+ 34 - 3
src/client/js/app.js

@@ -25,12 +25,13 @@ import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment   from './components/PageAttachment';
 import PageStatusAlert  from './components/PageStatusAlert';
-import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
+import LikeButton       from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import UserPictureList  from './components/Common/UserPictureList';
 
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -277,7 +278,6 @@ const componentMappings = {
   'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
 
   //'revision-history': <PageHistory pageId={pageId} />,
-  'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
@@ -289,7 +289,7 @@ const componentMappings = {
 // additional definitions if data exists
 if (pageId) {
   componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
-  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
+  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
 }
 if (pagePath) {
   componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} onSaveWithShortcut={saveWithShortcut} />;
@@ -309,6 +309,37 @@ if (componentInstances['page'] != null) {
   crowi.setPage(componentInstances['page']);
 }
 
+// render LikeButton
+const likeButtonElem = document.getElementById('like-button');
+if (likeButtonElem) {
+  const isLiked = likeButtonElem.dataset.liked === 'true';
+  ReactDOM.render(
+    <LikeButton crowi={crowi} pageId={pageId} isLiked={isLiked} />,
+    likeButtonElem
+  );
+}
+
+// render UserPictureList for seen-user-list
+const seenUserListElem = document.getElementById('seen-user-list');
+if (seenUserListElem) {
+  const userIdsStr = seenUserListElem.dataset.userIds;
+  const userIds = userIdsStr.split(',');
+  ReactDOM.render(
+    <UserPictureList crowi={crowi} userIds={userIds} />,
+    seenUserListElem
+  );
+}
+// render UserPictureList for liker-list
+const likerListElem = document.getElementById('liker-list');
+if (likerListElem) {
+  const userIdsStr = likerListElem.dataset.userIds;
+  const userIds = userIdsStr.split(',');
+  ReactDOM.render(
+    <UserPictureList crowi={crowi} userIds={userIds} />,
+    likerListElem
+  );
+}
+
 // render SavePageControls
 let savePageControls = null;
 const savePageControlsElem = document.getElementById('save-page-controls');

+ 1 - 1
src/client/js/components/BookmarkButton.js → src/client/js/components/BookmarkButton.jsx

@@ -74,7 +74,7 @@ export default class BookmarkButton extends React.Component {
 
     return (
       <button href="#" title="Bookmark" onClick={this.handleClick}
-          className={`bookmark-link btn btn-default btn-circle btn-outline ${addedClassName}`}>
+          className={`btn-bookmark btn btn-default btn-circle btn-outline ${addedClassName}`}>
         <i className="icon-star"></i>
       </button>
     );

+ 50 - 0
src/client/js/components/Common/UserPictureList.jsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserPicture from '../User/UserPicture';
+
+export default 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.crowi.findUserByIds(userIds)
+    );
+
+    this.state = {
+      users: users,
+    };
+
+  }
+
+  render() {
+    const users = this.state.users.map(user => {
+      return (
+        <a key={user._id} data-user-id={user._id} href={'/user/' + user.username} title={user.name}>
+          <UserPicture user={user} size="xs" />
+        </a>
+      );
+    });
+
+    return (
+      <span>
+        {users}
+      </span>
+    );
+  }
+}
+
+UserPictureList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  userIds: PropTypes.arrayOf(PropTypes.string),
+  users: PropTypes.arrayOf(PropTypes.object),
+};
+
+UserPictureList.defaultProps = {
+  userIds: [],
+  users: [],
+};

+ 66 - 0
src/client/js/components/LikeButton.jsx

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class LikeButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isLiked: !!props.isLiked,
+    };
+
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick(event) {
+    event.preventDefault();
+
+    const pageId = this.props.pageId;
+
+    if (!this.state.isLiked) {
+      this.props.crowi.apiPost('/likes.add', {page_id: pageId})
+      .then(res => {
+        this.setState({isLiked: true});
+      });
+    }
+    else {
+      this.props.crowi.apiPost('/likes.remove', {page_id: pageId})
+      .then(res => {
+        this.setState({isLiked: false});
+      });
+    }
+  }
+
+  isUserLoggedIn() {
+    return this.props.crowi.me !== '';
+  }
+
+  render() {
+    // if guest user
+    if (!this.isUserLoggedIn()) {
+      return <div></div>;
+    }
+
+    const btnSizeClassName = this.props.size ? `btn-${this.props.size}` : 'btn-md';
+    const addedClassNames = [
+      this.state.isLiked ? 'active' : '',
+      btnSizeClassName,
+    ];
+    const addedClassName = addedClassNames.join(' ');
+
+    return (
+      <button href="#" title="Like" onClick={this.handleClick}
+          className={`btn-like btn btn-default btn-circle btn-outline ${addedClassName}`}>
+        <i className="icon-like"></i>
+      </button>
+    );
+  }
+}
+
+LikeButton.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  isLiked: PropTypes.bool,
+  size: PropTypes.string,
+};

+ 8 - 1
src/client/js/components/PageAttachment.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
@@ -43,7 +44,7 @@ export default class PageAttachment extends React.Component {
   }
 
   checkIfFileInUse(attachment) {
-    if (this.props.pageContent.match(attachment.url)) {
+    if (this.props.markdown.match(attachment.filePathProxied)) {
       return true;
     }
     return false;
@@ -124,3 +125,9 @@ export default class PageAttachment extends React.Component {
     );
   }
 }
+
+PageAttachment.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pageId: PropTypes.string.isRequired,
+};

+ 2 - 3
src/client/js/components/PageAttachment/Attachment.js

@@ -35,7 +35,7 @@ export default class Attachment extends React.Component {
 
     const btnDownload = (this.props.isUserLoggedIn)
       ? (
-        <a className="attachment-download" href={`/download/${attachment._id}`}>
+        <a className="attachment-download" href={attachment.downloadPathProxied}>
           <i className="icon-cloud-download"></i>
         </a>)
       : '';
@@ -50,9 +50,8 @@ export default class Attachment extends React.Component {
     return (
       <li>
         <User user={attachment.creator} />
-        <i className={formatIcon}></i>
 
-        <a href={attachment.url}> {attachment.originalName}</a>
+        <a href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
 
         {fileType}
 

+ 1 - 1
src/client/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -25,7 +25,7 @@ export default class DeleteAttachmentModal extends React.Component {
 
   renderByFileFormat(attachment) {
     const content = (attachment.fileFormat.match(/image\/.+/i))
-      ? <img src={attachment.url} />
+      ? <img src={attachment.filePathProxied} />
       : '';
 
 

+ 1 - 2
src/client/js/components/PageComment/CommentForm.jsx

@@ -181,11 +181,10 @@ export default class CommentForm extends React.Component {
     // post
     this.props.crowi.apiPost(endpoint, formData)
       .then((res) => {
-        const url = res.url;
         const attachment = res.attachment;
         const fileName = attachment.originalName;
 
-        let insertText = `[${fileName}](${url})`;
+        let insertText = `[${fileName}](${attachment.filePathProxied})`;
         // when image
         if (attachment.fileFormat.startsWith('image/')) {
           // modify to "![fileName](url)" syntax

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

@@ -141,12 +141,11 @@ export default class PageEditor extends React.Component {
       formData.append('page_id', this.state.pageId || 0);
 
       // post
-      res = await this.props.crowi.apiPost(endpoint, formData)
-      const url = res.url;
+      res = await this.props.crowi.apiPost(endpoint, formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
-      let insertText = `[${fileName}](${url})`;
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
       // when image
       if (attachment.fileFormat.startsWith('image/')) {
         // modify to "![fileName](url)" syntax

+ 0 - 52
src/client/js/components/SeenUserList.js

@@ -1,52 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserList from './SeenUserList/UserList';
-
-export default class SeenUserList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      seenUsers: [],
-    };
-  }
-
-  componentDidMount() {
-    const seenUserIds = this.getSeenUserIds();
-
-    if (seenUserIds.length > 0) {
-      // FIXME: user data cache
-      this.setState({seenUsers: this.props.crowi.findUserByIds(seenUserIds)});
-    }
-  }
-
-  getSeenUserIds() {
-    // FIXME: Consider another way to bind values.
-    const $seenUserList = $('#seen-user-list');
-    if ($seenUserList.length > 0) {
-      const seenUsers = $seenUserList.data('seen-users');
-      if (seenUsers) {
-        return seenUsers.split(',');
-      }
-    }
-
-    return [];
-  }
-
-  render() {
-    return (
-      <div className="seen-user-list">
-        <p className="seen-user-count">
-          {this.state.seenUsers.length}
-        </p>
-        <UserList users={this.state.seenUsers} />
-      </div>
-    );
-  }
-}
-
-SeenUserList.propTypes = {
-  crowi: PropTypes.object.isRequired,
-};

+ 0 - 44
src/client/js/components/SeenUserList/UserList.js

@@ -1,44 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserPicture from '../User/UserPicture';
-
-export default class UserList extends React.Component {
-
-  isSeenUserListShown() {
-    const userCount = this.props.users.length;
-    if (0 < userCount && userCount <= 10) {
-      return true;
-    }
-
-    return false;
-  }
-
-  render() {
-    if (!this.isSeenUserListShown()) {
-      return null;
-    }
-
-    const users = this.props.users.map((user) => {
-      return (
-        <a key={user._id} data-user-id={user._id} href={'/user/' + user.username} title={user.name}>
-          <UserPicture user={user} size="xs" />
-        </a>
-      );
-    });
-
-    return (
-      <p className="seen-user-list">
-        {users}
-      </p>
-    );
-  }
-}
-
-UserList.propTypes = {
-  users: PropTypes.array,
-};
-
-UserList.defaultProps = {
-  users: [],
-};

+ 7 - 1
src/client/js/components/User/UserPicture.js

@@ -11,8 +11,14 @@ export default class UserPicture extends React.Component {
       return this.generateGravatarSrc(user);
     }
     // uploaded image
+    else if (user.image != null) {
+      return user.image;
+    }
+    else if (user.imageAttachment != null) {
+      return user.imageAttachment.filePathProxied;
+    }
     else {
-      return user.image || '/images/icons/user.svg';
+      return '/images/icons/user.svg';
     }
   }
 

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

@@ -581,72 +581,6 @@ $(function() {
       top.location.href = `${path}#edit`;
     });
 
-    // Like
-    const $likeButton = $('.like-button');
-    const $likeCount = $('#like-count');
-    $likeButton.click(function() {
-      const liked = $likeButton.data('liked');
-      const token = $likeButton.data('csrftoken');
-      if (!liked) {
-        $.post('/_api/likes.add', {_csrf: token, page_id: pageId}, function(res) {
-          if (res.ok) {
-            MarkLiked();
-          }
-        });
-      }
-      else {
-        $.post('/_api/likes.remove', {_csrf: token, page_id: pageId}, function(res) {
-          if (res.ok) {
-            MarkUnLiked();
-          }
-        });
-      }
-
-      return false;
-    });
-    const $likerList = $('#liker-list');
-    const likers = $likerList.data('likers');
-    if (likers && likers.length > 0) {
-      const users = crowi.findUserByIds(likers.split(','));
-      if (users) {
-        AddToLikers(users);
-      }
-    }
-
-    /* eslint-disable no-inner-declarations */
-    function AddToLikers(users) {
-      $.each(users, function(i, user) {
-        $likerList.append(CreateUserLinkWithPicture(user));
-      });
-    }
-
-    function MarkLiked() {
-      $likeButton.addClass('active');
-      $likeButton.data('liked', 1);
-      $likeCount.text(parseInt($likeCount.text()) + 1);
-    }
-
-    function MarkUnLiked() {
-      $likeButton.removeClass('active');
-      $likeButton.data('liked', 0);
-      $likeCount.text(parseInt($likeCount.text()) - 1);
-    }
-
-    function CreateUserLinkWithPicture(user) {
-      const $userHtml = $('<a>');
-      $userHtml.data('user-id', user._id);
-      $userHtml.attr('href', '/user/' + user.username);
-      $userHtml.attr('title', user.name);
-
-      const $userPicture = $('<img class="picture picture-xs img-circle">');
-      $userPicture.attr('alt', user.name);
-      $userPicture.attr('src',  Crowi.userPicture(user));
-
-      $userHtml.append($userPicture);
-      return $userHtml;
-    }
-    /* eslint-enable */
-
     if (!isSeen) {
       $.post('/_api/pages.seen', {page_id: pageId}, function(res) {
         // ignore unless response has error

+ 200 - 202
src/client/styles/scss/_layout_crowi_sidebar.scss

@@ -1,202 +1,200 @@
-.crowi-sidebar { // {{{
-  position: fixed;
-  padding: 65px 0 0 0;
-  height: 100%;
-  right: 0;
-  top: 0;
-  overflow: auto;
-  border-left: solid 1px transparent;
-
-  transition: .3s ease;
-
-
-  .page-meta {
-    padding: 15px 15px 5px 15px;
-    font-size: .9em;
-    border-bottom: solid 1px #ccc;
-
-    line-height: 1.4em;
-    p {
-      line-height: 1.4em;
-    }
-
-    .creator-picture {
-      text-align: center;
-      img {
-        width: 48px;
-        height: 48px;
-        border: 1px solid #ccc;
-      }
-    }
-    .creator {
-      font-size: 1.3em;
-      font-weight: bold;
-    }
-    .created-at {
-    }
-
-    .like-box {
-      padding-bottom: 0;
-
-      .dl-horizontal {
-        margin-bottom: 0;
-
-        dt, dd {
-          border-top: solid 1px #ccc;
-          padding-top: 5px;
-          padding-bottom: 5px;
-        }
-        dt {
-          width: 80px;
-        }
-        dd {
-          margin-left: 90px;
-          text-align: right;
-        }
-      }
-    }
-
-    .liker-count, .contributor-count, .seen-user-count {
-      font-size: 1.2em;
-      font-weight: bold;
-      margin-bottom: 5px;
-    }
-    .contributor-list, .seen-user-list {
-    }
-  }
-
-  .side-content {
-    margin-bottom: 100px;
-    padding: 15px;
-
-    h3 {
-      font-size: 1.1em;
-    }
-
-    ul.fitted-list {
-      padding-left: 0;
-      li {
-        margin-bottom: 2px;
-
-        .input-group-addon {
-          padding: 5px 6px;
-        }
-      }
-    }
-
-    .page-comments {
-      margin: 8px 0 0 0;
-
-      .page-comment-form {
-        margin-top: 16px;
-
-        .comment-form {
-        }
-
-        .comment-form-main {
-
-          .comment-form-comment {
-            height: 60px;
-          }
-
-          .comment-submit {
-            margin-top: 8px;
-            text-align: right;
-          }
-        }
-      }
-
-      .page-comments-list {
-        .page-comment {
-          margin-top: 8px;
-          padding-top: 8px;
-
-          .picture {
-            float: left;
-            width: 24px;
-            height: 24px;
-          }
-
-          .page-comment-creator {
-            font-weight: bold;
-          }
-
-          .page-comment-main {
-            position: relative;
-            margin-left: 40px;
-
-            .page-comment-meta {
-              color: #aaa;
-              font-size: .9em;
-            }
-            .page-comment-body {
-              padding: 8px 0;
-              word-wrap: break-word;
-            }
-            .page-comment-control {
-              position: absolute;
-              display: none;    // default hidden
-              top: 0;
-              right: 0;
-            }
-          }
-
-          // show controls when hover
-          .page-comment-main:hover > .page-comment-control {
-            display: block;
-          }
-        }
-      }
-    }
-
-  }
-
-  .portal-form-button {
-    text-align: center;
-  }
-
-  .system-version {
-    position: fixed;
-    z-index: 1;
-    right: 1.4em;
-    width: calc(25% - 1.5em);
-    bottom: 0.1em;
-    padding-right: 1em;
-    opacity: 1;
-
-    display: flex;
-    justify-content: space-between;
-
-    transition: .3s ease;
-  }
-
-} // }}}
-
-body:not(.aside-hidden) #toggle-sidebar {
-  i.ti-angle-left {
-    display: none;
-  }
-  i.ti-angle-right {
-    display: block;
-  }
-}
-.aside-hidden { // {{{
-  #toggle-sidebar {
-    right: 0;
-    i.ti-angle-right {
-      display: block;
-    }
-    i.ti-angle-right {
-      display: none;
-    }
-  }
-
-  .crowi-sidebar, .system-version { // {{{
-    right: -25%;
-  } // }}}
-
-  .bg-title .col-md-9,
-  .main {
-    width: 100%;
-  }
-} // }}}
+.crowi-sidebar { // {{{
+  position: fixed;
+  padding: 65px 0 0 0;
+  height: 100%;
+  right: 0;
+  top: 0;
+  overflow: auto;
+  border-left: solid 1px transparent;
+
+  transition: .3s ease;
+
+
+  .page-meta {
+    padding: 15px 15px 5px 15px;
+    font-size: .9em;
+    border-bottom: solid 1px #ccc;
+
+    line-height: 1.4em;
+    p {
+      line-height: 1.4em;
+    }
+
+    .creator-picture {
+      text-align: center;
+      img {
+        width: 48px;
+        height: 48px;
+        border: 1px solid #ccc;
+      }
+    }
+    .creator {
+      font-size: 1.3em;
+      font-weight: bold;
+    }
+    .created-at {
+    }
+
+    .like-box {
+      padding-bottom: 0;
+
+      .dl-horizontal {
+        margin-bottom: 0;
+
+        dt, dd {
+          border-top: solid 1px #ccc;
+          padding-top: 5px;
+          padding-bottom: 5px;
+        }
+        dt {
+          width: 80px;
+        }
+        dd {
+          margin-left: 90px;
+          text-align: right;
+        }
+      }
+    }
+
+    .liker-user-count, .seen-user-count {
+      font-size: 1.2em;
+      font-weight: bold;
+      margin-bottom: 5px;
+    }
+  }
+
+  .side-content {
+    margin-bottom: 100px;
+    padding: 15px;
+
+    h3 {
+      font-size: 1.1em;
+    }
+
+    ul.fitted-list {
+      padding-left: 0;
+      li {
+        margin-bottom: 2px;
+
+        .input-group-addon {
+          padding: 5px 6px;
+        }
+      }
+    }
+
+    .page-comments {
+      margin: 8px 0 0 0;
+
+      .page-comment-form {
+        margin-top: 16px;
+
+        .comment-form {
+        }
+
+        .comment-form-main {
+
+          .comment-form-comment {
+            height: 60px;
+          }
+
+          .comment-submit {
+            margin-top: 8px;
+            text-align: right;
+          }
+        }
+      }
+
+      .page-comments-list {
+        .page-comment {
+          margin-top: 8px;
+          padding-top: 8px;
+
+          .picture {
+            float: left;
+            width: 24px;
+            height: 24px;
+          }
+
+          .page-comment-creator {
+            font-weight: bold;
+          }
+
+          .page-comment-main {
+            position: relative;
+            margin-left: 40px;
+
+            .page-comment-meta {
+              color: #aaa;
+              font-size: .9em;
+            }
+            .page-comment-body {
+              padding: 8px 0;
+              word-wrap: break-word;
+            }
+            .page-comment-control {
+              position: absolute;
+              display: none;    // default hidden
+              top: 0;
+              right: 0;
+            }
+          }
+
+          // show controls when hover
+          .page-comment-main:hover > .page-comment-control {
+            display: block;
+          }
+        }
+      }
+    }
+
+  }
+
+  .portal-form-button {
+    text-align: center;
+  }
+
+  .system-version {
+    position: fixed;
+    z-index: 1;
+    right: 1.4em;
+    width: calc(25% - 1.5em);
+    bottom: 0.1em;
+    padding-right: 1em;
+    opacity: 1;
+
+    display: flex;
+    justify-content: space-between;
+
+    transition: .3s ease;
+  }
+
+} // }}}
+
+body:not(.aside-hidden) #toggle-sidebar {
+  i.ti-angle-left {
+    display: none;
+  }
+  i.ti-angle-right {
+    display: block;
+  }
+}
+.aside-hidden { // {{{
+  #toggle-sidebar {
+    right: 0;
+    i.ti-angle-right {
+      display: block;
+    }
+    i.ti-angle-right {
+      display: none;
+    }
+  }
+
+  .crowi-sidebar, .system-version { // {{{
+    right: -25%;
+  } // }}}
+
+  .bg-title .col-md-9,
+  .main {
+    width: 100%;
+  }
+} // }}}

+ 9 - 0
src/client/styles/scss/_layout_growi.scss

@@ -3,6 +3,15 @@
     padding: 0;
   }
 
+  .liker-and-seenusers {
+    height: 42px;   // .nav height
+    border-bottom: 1px solid $border;
+
+    .liker-user-count, .seen-user-count {
+      font-weight: bold;
+    }
+  }
+
   .revision-toc {
     &.affix {
       margin-top: 5px;

+ 1 - 1
src/client/styles/scss/_on-edit.scss

@@ -40,7 +40,7 @@ body.on-edit {
   .portal-form-button,
   .alert-info.alert-moved,
   .alert-info.alert-unlinked,
-  .like-button, .bookmark-link, .btn-edit,
+  .btn-like, .btn-bookmark, .btn-edit,
   .authors,
   footer {
     display: none !important;

+ 3 - 3
src/client/styles/scss/_page.scss

@@ -28,7 +28,7 @@
       }
     }
 
-    .like-button, .bookmark-link {
+    .btn-like, .btn-bookmark {
       border: none;
       font-size: 1.2em;
       line-height: 0.8em;
@@ -36,12 +36,12 @@
         background-color: transparent;
       }
     }
-    .like-button {
+    .btn-like {
       &.active {
         @extend .btn-info;
       }
     }
-    .bookmark-link {
+    .btn-bookmark {
       &.active {
         @extend .btn-warning;
       }

+ 1 - 1
src/client/styles/scss/_user.scss

@@ -38,7 +38,7 @@
       }
     }
 
-    .like-button, .bookmark-link {
+    .btn-like, .btn-bookmark {
       &.btn-lg {
         font-size: 1.5em;
         padding: 8px;

+ 15 - 15
src/client/styles/scss/_user_growi.scss

@@ -1,15 +1,15 @@
-.growi.main-container .user-page {
-
-  // affix
-  .user-page-header.affix {
-    #revision-path {
-      display: none;
-    }
-  }
-
-  .revision-toc {
-    &.affix {
-      top: 130px;
-    }
-  }
-}
+.growi.main-container .user-page {
+
+  // affix
+  .user-page-header.affix {
+    #revision-path {
+      display: none;
+    }
+  }
+
+  .revision-toc {
+    &.affix {
+      top: 105px;
+    }
+  }
+}

+ 50 - 137
src/server/models/attachment.js

@@ -1,147 +1,80 @@
+const debug = require('debug')('growi:models:attachment');
+const logger = require('@alias/logger')('growi:models:attachment');
+const path = require('path');
+
+const mongoose = require('mongoose');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
 module.exports = function(crowi) {
-  var debug = require('debug')('growi:models:attachment')
-    , mongoose = require('mongoose')
-    , ObjectId = mongoose.Schema.Types.ObjectId
-    , fileUploader = require('../service/file-uploader')(crowi)
-    , attachmentSchema
-  ;
+  const fileUploader = require('../service/file-uploader')(crowi);
+
+  let attachmentSchema;
 
   function generateFileHash(fileName) {
-    var hasher = require('crypto').createHash('md5');
-    hasher.update(fileName);
+    const hash = require('crypto').createHash('md5');
+    hash.update(`${fileName}_${Date.now()}`);
 
-    return hasher.digest('hex');
+    return hash.digest('hex');
   }
 
+
   attachmentSchema = new mongoose.Schema({
     page: { type: ObjectId, ref: 'Page', index: true },
     creator: { type: ObjectId, ref: 'User', index: true  },
-    filePath: { type: String, required: true },
+    filePath: { type: String },   // DEPRECATED: remains for backward compatibility for v3.3.x or below
     fileName: { type: String, required: true },
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
-    createdAt: { type: Date, default: Date.now },
-  }, {
-    toJSON: {
-      virtuals: true
-    },
+    createdAt: { type: Date, default: Date.now() },
   });
 
-  attachmentSchema.virtual('fileUrl').get(function() {
-    // NOTE: use original generated Url directly (not proxy) -- 2017.05.08 Yuki Takei
-    // reason:
-    //   1. this is buggy (doesn't work on Win)
-    //   2. ensure backward compatibility of data
-
-    // return `/files/${this._id}`;
-    return fileUploader.generateUrl(this.filePath);
+  attachmentSchema.virtual('filePathProxied').get(function() {
+    return `/attachment/${this._id}`;
   });
 
-  attachmentSchema.statics.findById = function(id) {
-    var Attachment = this;
-
-    return new Promise(function(resolve, reject) {
-      Attachment.findOne({_id: id}, function(err, data) {
-        if (err) {
-          return reject(err);
-        }
-
-        if (data === null) {
-          return reject(new Error('Attachment not found'));
-        }
-        return resolve(data);
-      });
-    });
-  };
-
-  attachmentSchema.statics.getListByPageId = function(id) {
-    var self = this;
-
-    return new Promise(function(resolve, reject) {
-
-      self
-        .find({page: id})
-        .sort({'updatedAt': 1})
-        .populate('creator')
-        .exec(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          if (data.length < 1) {
-            return resolve([]);
-          }
-
-          debug(data);
-          return resolve(data);
-        });
-    });
-  };
-
-  attachmentSchema.statics.create = function(pageId, creator, filePath, originalName, fileName, fileFormat, fileSize) {
-    var Attachment = this;
-
-    return new Promise(function(resolve, reject) {
-      var newAttachment = new Attachment();
-
-      newAttachment.page = pageId;
-      newAttachment.creator = creator._id;
-      newAttachment.filePath = filePath;
-      newAttachment.originalName = originalName;
-      newAttachment.fileName = fileName;
-      newAttachment.fileFormat = fileFormat;
-      newAttachment.fileSize = fileSize;
-      newAttachment.createdAt = Date.now();
-
-      newAttachment.save(function(err, data) {
-        if (err) {
-          debug('Error on saving attachment.', err);
-          return reject(err);
-        }
-        debug('Attachment saved.', data);
-        return resolve(data);
-      });
-    });
-  };
+  attachmentSchema.virtual('downloadPathProxied').get(function() {
+    return `/download/${this._id}`;
+  });
 
-  attachmentSchema.statics.guessExtByFileType = function(fileType) {
-    let ext = '';
-    const isImage = fileType.match(/^image\/(.+)/i);
+  attachmentSchema.set('toObject', { virtuals: true });
+  attachmentSchema.set('toJSON', { virtuals: true });
 
-    if (isImage) {
-      ext = isImage[1].toLowerCase();
-    }
 
-    return ext;
-  };
-
-  attachmentSchema.statics.createAttachmentFilePath = function(pageId, fileName, fileType) {
+  attachmentSchema.statics.create = async function(pageId, user, fileStream, originalName, fileFormat, fileSize) {
     const Attachment = this;
-    let ext = '';
-    const fnExt = fileName.match(/(.*)(?:\.([^.]+$))/);
 
-    if (fnExt) {
-      ext = '.' + fnExt[2];
-    }
-    else {
-      ext = Attachment.guessExtByFileType(fileType);
-      if (ext !== '') {
-        ext = '.' + ext;
-      }
+    const extname = path.extname(originalName);
+    let fileName = generateFileHash(originalName);
+    if (extname.length > 1) {   // ignore if empty or '.' only
+      fileName = `${fileName}${extname}`;
     }
 
-    return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
+    let attachment = new Attachment();
+    attachment.page = pageId;
+    attachment.creator = user._id;
+    attachment.originalName = originalName;
+    attachment.fileName = fileName;
+    attachment.fileFormat = fileFormat;
+    attachment.fileSize = fileSize;
+    attachment.createdAt = Date.now();
+
+    // upload file
+    await fileUploader.uploadFile(fileStream, attachment);
+    // save attachment
+    attachment = await attachment.save();
+
+    return attachment;
   };
 
   attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
     var Attachment = this;
 
     return new Promise((resolve, reject) => {
-      Attachment.getListByPageId(pageId)
+      Attachment.find({ page: pageId})
       .then((attachments) => {
         for (let attachment of attachments) {
-          Attachment.removeAttachment(attachment).then((res) => {
+          Attachment.removeWithSubstanceById(attachment._id).then((res) => {
             // do nothing
           }).catch((err) => {
             debug('Attachment remove error', err);
@@ -156,31 +89,11 @@ module.exports = function(crowi) {
 
   };
 
-  attachmentSchema.statics.findDeliveryFile = function(attachment, forceUpdate) {
-    // TODO force update
-    // var forceUpdate = forceUpdate || false;
-
-    return fileUploader.findDeliveryFile(attachment._id, attachment.filePath);
-  };
-
-  attachmentSchema.statics.removeAttachment = function(attachment) {
-    const Attachment = this;
-    const filePath = attachment.filePath;
-
-    return new Promise((resolve, reject) => {
-      Attachment.remove({_id: attachment._id}, (err, data) => {
-        if (err) {
-          return reject(err);
-        }
-
-        fileUploader.deleteFile(attachment._id, filePath)
-        .then(data => {
-          resolve(data); // this may null
-        }).catch(err => {
-          reject(err);
-        });
-      });
-    });
+  attachmentSchema.statics.removeWithSubstanceById = async function(id) {
+    // retrieve data from DB to get a completely populated instance
+    const attachment = await this.findById(id);
+    await fileUploader.deleteFile(attachment);
+    return await attachment.remove();
   };
 
   return mongoose.model('Attachment', attachmentSchema);

+ 2 - 40
src/server/models/comment.js

@@ -43,49 +43,11 @@ module.exports = function(crowi) {
   };
 
   commentSchema.statics.getCommentsByPageId = function(id) {
-    var self = this;
-
-    return new Promise(function(resolve, reject) {
-      self
-        .find({page: id})
-        .sort({'createdAt': -1})
-        .populate('creator', USER_PUBLIC_FIELDS)
-        .exec(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          if (data.length < 1) {
-            return resolve([]);
-          }
-
-          //debug('Comment loaded', data);
-          return resolve(data);
-        });
-    });
+    return this.find({page: id}).sort({'createdAt': -1});
   };
 
   commentSchema.statics.getCommentsByRevisionId = function(id) {
-    var self = this;
-
-    return new Promise(function(resolve, reject) {
-      self
-        .find({revision: id})
-        .sort({'createdAt': -1})
-        .populate('creator', USER_PUBLIC_FIELDS)
-        .exec(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          if (data.length < 1) {
-            return resolve([]);
-          }
-
-          debug('Comment loaded', data);
-          return resolve(data);
-        });
-    });
+    return this.find({revision: id}).sort({'createdAt': -1});
   };
 
   commentSchema.statics.countCommentByPageId = function(page) {

+ 33 - 18
src/server/models/page.js

@@ -96,14 +96,16 @@ const addSlashOfEnd = (path) => {
  * @param {any} page Query or Document
  * @param {string} userPublicFields string to set to select
  */
-const populateDataToShowRevision = (page, userPublicFields) => {
+const populateDataToShowRevision = (page, userPublicFields, imagePopulation) => {
   return page
-    .populate({ path: 'lastUpdateUser', model: 'User', select: userPublicFields })
-    .populate({ path: 'creator', model: 'User', select: userPublicFields })
-    .populate({ path: 'grantedGroup', model: 'UserGroup' })
-    .populate({ path: 'revision', model: 'Revision', populate: {
-      path: 'author', model: 'User', select: userPublicFields
-    } });
+    .populate([
+      { path: 'lastUpdateUser', model: 'User', select: userPublicFields, populate: imagePopulation },
+      { path: 'creator', model: 'User', select: userPublicFields, populate: imagePopulation },
+      { path: 'grantedGroup', model: 'UserGroup' },
+      { path: 'revision', model: 'Revision', populate: {
+        path: 'author', model: 'User', select: userPublicFields, populate: imagePopulation
+      }}
+    ]);
 };
 
 
@@ -237,8 +239,18 @@ class PageQueryBuilder {
     return this;
   }
 
-  populateDataToShowRevision(userPublicFields) {
-    this.query = populateDataToShowRevision(this.query, userPublicFields);
+  populateDataToList(userPublicFields, imagePopulation) {
+    this.query = this.query
+      .populate({
+        path: 'lastUpdateUser',
+        select: userPublicFields,
+        populate: imagePopulation
+      });
+    return this;
+  }
+
+  populateDataToShowRevision(userPublicFields, imagePopulation) {
+    this.query = populateDataToShowRevision(this.query, userPublicFields, imagePopulation);
     return this;
   }
 
@@ -419,7 +431,7 @@ module.exports = function(crowi) {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS)
+    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION)
       .execPopulate();
   };
 
@@ -715,10 +727,12 @@ module.exports = function(crowi) {
     builder.addConditionToExcludeRedirect();
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
+    // count
     const totalCount = await builder.query.exec('count');
-    const q = builder.query
-      .populate({ path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS });
-    const pages = await q.exec('find');
+
+    // find
+    builder.populateDataToList(User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION);
+    const pages = await builder.query.exec('find');
 
     const result = { pages, totalCount, offset: opt.offset, limit: opt.limit };
     return result;
@@ -753,12 +767,13 @@ module.exports = function(crowi) {
     // add grant conditions
     await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
 
-    builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
-
+    // count
     const totalCount = await builder.query.exec('count');
-    const q = builder.query
-      .populate({ path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS });
-    const pages = await q.exec('find');
+
+    // find
+    builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
+    builder.populateDataToList(User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION);
+    const pages = await builder.query.exec('find');
 
     const result = { pages, totalCount, offset: opt.offset, limit: opt.limit };
     return result;

+ 1 - 18
src/server/models/user-group.js

@@ -1,7 +1,6 @@
 const debug = require('debug')('growi:models:userGroup');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate');
-const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
 /*
@@ -9,7 +8,6 @@ const ObjectId = mongoose.Schema.Types.ObjectId;
  */
 const schema = new mongoose.Schema({
   userGroupId: String,
-  image: String,
   name: { type: String, required: true, unique: true },
   createdAt: { type: Date, default: Date.now },
 });
@@ -25,7 +23,7 @@ class UserGroup {
    * @memberof UserGroup
    */
   static get USER_GROUP_PUBLIC_FIELDS() {
-    return '_id image name createdAt';
+    return '_id name createdAt';
   }
 
   /**
@@ -125,21 +123,6 @@ class UserGroup {
     return this.create({name: name});
   }
 
-  /*
-   * instance methods
-   */
-
-  // グループ画像の更新
-  updateImage(image) {
-    this.image = image;
-    return this.save();
-  }
-
-  // グループ画像の削除
-  deleteImage() {
-    return this.updateImage(null);
-  }
-
   // グループ名の更新
   updateName(name) {
     // 名前を設定して更新

+ 61 - 73
src/server/models/user.js

@@ -1,26 +1,28 @@
+const debug = require('debug')('growi:models:user');
+const logger = require('@alias/logger')('growi:models:user');
+const path = require('path');
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+const mongoosePaginate = require('mongoose-paginate');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+const crypto = require('crypto');
+const async = require('async');
+
 module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:user')
-    , logger = require('@alias/logger')('growi:models:user')
-    , path = require('path')
-    , mongoose = require('mongoose')
-    , mongoosePaginate = require('mongoose-paginate')
-    , uniqueValidator = require('mongoose-unique-validator')
-    , crypto = require('crypto')
-    , async = require('async')
-
-    , STATUS_REGISTERED = 1
-    , STATUS_ACTIVE     = 2
-    , STATUS_SUSPENDED  = 3
-    , STATUS_DELETED    = 4
-    , STATUS_INVITED    = 5
-    , USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction status lang createdAt admin' // TODO: どこか別の場所へ...
-
-    , LANG_EN    = 'en'
-    , LANG_EN_US = 'en-US'
-    , LANG_EN_GB = 'en-GB'
-    , LANG_JA    = 'ja'
-
-    , PAGE_ITEMS        = 50
+  const STATUS_REGISTERED = 1;
+  const STATUS_ACTIVE     = 2;
+  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 admin';
+  const IMAGE_POPULATION = { path: 'imageAttachment', select: 'filePathProxied' };
+
+  const LANG_EN    = 'en';
+  const LANG_EN_US = 'en-US';
+  const LANG_EN_GB = 'en-GB';
+  const LANG_JA    = 'ja';
+
+  const PAGE_ITEMS = 50;
 
   let userSchema;
   let userEvent;
@@ -34,6 +36,7 @@ module.exports = function(crowi) {
   userSchema = new mongoose.Schema({
     userId: String,
     image: String,
+    imageAttachment: { type: ObjectId, ref: 'Attachment' },
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },
     googleId: String,
@@ -133,6 +136,10 @@ module.exports = function(crowi) {
     return lang;
   }
 
+  userSchema.methods.populateImage = async function() {
+    return await this.populate(IMAGE_POPULATION);
+  };
+
   userSchema.methods.isPasswordSet = function() {
     if (this.password) {
       return true;
@@ -170,7 +177,6 @@ module.exports = function(crowi) {
     });
   };
 
-
   userSchema.methods.updateIsEmailPublished = function(isEmailPublished, callback) {
     this.isEmailPublished = isEmailPublished;
     this.save(function(err, userData) {
@@ -202,15 +208,24 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.methods.updateImage = function(image, callback) {
-    this.image = image;
-    this.save(function(err, userData) {
-      return callback(err, userData);
-    });
+  userSchema.methods.updateImage = async function(attachment) {
+    this.imageAttachment = attachment;
+    return this.save();
   };
 
-  userSchema.methods.deleteImage = function(callback) {
-    return this.updateImage(null, callback);
+  userSchema.methods.deleteImage = async function() {
+    validateCrowi();
+    const Attachment = crowi.model('Attachment');
+
+    // the 'image' field became DEPRECATED in v3.3.8
+    this.image = undefined;
+
+    if (this.imageAttachment != null) {
+      Attachment.removeWithSubstance(this.imageAttachment._id);
+    }
+
+    this.imageAttachment = undefined;
+    return this.save();
   };
 
   userSchema.methods.updateGoogleId = function(googleId, callback) {
@@ -367,55 +382,33 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.findAllUsers = function(option) {
-    var User = this;
-    var option = option || {}
-      , sort = option.sort || {createdAt: -1}
-      , status = option.status || [STATUS_ACTIVE, STATUS_SUSPENDED]
-      , fields = option.fields || USER_PUBLIC_FIELDS
-      ;
+    option = option || {};
+
+    const sort = option.sort || {createdAt: -1};
+    const fields = option.fields || USER_PUBLIC_FIELDS;
 
+    let status = option.status || [STATUS_ACTIVE, STATUS_SUSPENDED];
     if (!Array.isArray(status)) {
       status = [status];
     }
 
-    return new Promise(function(resolve, reject) {
-      User
-        .find()
-        .or(status.map(s => { return {status: s} }))
-        .select(fields)
-        .sort(sort)
-        .exec(function(err, userData) {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(userData);
-        });
-    });
+    return this.find()
+      .or(status.map(s => { return {status: s} }))
+      .select(fields)
+      .sort(sort);
   };
 
   userSchema.statics.findUsersByIds = function(ids, option) {
-    var User = this;
-    var option = option || {}
-      , sort = option.sort || {createdAt: -1}
+    option = option || {};
+
+    const sort = option.sort || {createdAt: -1}
       , status = option.status || STATUS_ACTIVE
       , fields = option.fields || USER_PUBLIC_FIELDS
       ;
 
-
-    return new Promise(function(resolve, reject) {
-      User
-        .find({ _id: { $in: ids }, status: status })
-        .select(fields)
-        .sort(sort)
-        .exec(function(err, userData) {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(userData);
-        });
-    });
+    return this.find({ _id: { $in: ids }, status: status })
+      .select(fields)
+      .sort(sort);
   };
 
   userSchema.statics.findAdmins = function(callback) {
@@ -805,12 +798,6 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.statics.createUserPictureFilePath = function(user, name) {
-    var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
-
-    return 'user/' + user._id + ext;
-  };
-
   userSchema.statics.getUsernameByPath = function(path) {
     var username = null;
     if (m = path.match(/^\/user\/([^\/]+)\/?/)) {
@@ -832,6 +819,7 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_DELETED     = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED     = STATUS_INVITED;
   userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
+  userSchema.statics.IMAGE_POPULATION   = IMAGE_POPULATION;
   userSchema.statics.PAGE_ITEMS         = PAGE_ITEMS;
 
   userSchema.statics.LANG_EN            = LANG_EN;

+ 0 - 108
src/server/routes/admin.js

@@ -785,120 +785,12 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.userGroup.uploadGroupPicture = function(req, res) {
-    var fileUploader = require('../service/file-uploader')(crowi, app);
-    //var storagePlugin = new pluginService('storage');
-    //var storage = require('../service/storage').StorageService(config);
-
-    var userGroupId = req.params.userGroupId;
-
-    var tmpFile = req.file || null;
-    if (!tmpFile) {
-      return res.json({
-        'status': false,
-        'message': 'File type error.'
-      });
-    }
-
-    UserGroup.findById(userGroupId, function(err, userGroupData) {
-      if (!userGroupData) {
-        return res.json({
-          'status': false,
-          'message': 'UserGroup error.'
-        });
-      }
-
-      var tmpPath = tmpFile.path;
-      var filePath = UserGroup.createUserGroupPictureFilePath(userGroupData, tmpFile.filename + tmpFile.originalname);
-      var acceptableFileType = /image\/.+/;
-
-      if (!tmpFile.mimetype.match(acceptableFileType)) {
-        return res.json({
-          'status': false,
-          'message': 'File type error. Only image files is allowed to set as user picture.',
-        });
-      }
-
-      var tmpFileStream = fs.createReadStream(tmpPath, { flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true });
-
-      fileUploader.uploadFile(filePath, tmpFile.mimetype, tmpFileStream, {})
-        .then(function(data) {
-          var imageUrl = fileUploader.generateUrl(filePath);
-          userGroupData.updateImage(imageUrl)
-          .then(() => {
-            fs.unlink(tmpPath, function(err) {
-              if (err) {
-                debug('Error while deleting tmp file.', err);
-              }
-
-              return res.json({
-                'status': true,
-                'url': imageUrl,
-                'message': '',
-              });
-            });
-          });
-        }).catch(function(err) {
-          debug('Uploading error', err);
-
-          return res.json({
-            'status': false,
-            'message': 'Error while uploading to ',
-          });
-        });
-    });
-
-  };
-
-  actions.userGroup.deletePicture = function(req, res) {
-
-    const userGroupId = req.params.userGroupId;
-    let userGroupName = null;
-
-    UserGroup.findById(userGroupId)
-    .then((userGroupData) => {
-      if (userGroupData == null) {
-        return Promise.reject();
-      }
-      else {
-        userGroupName = userGroupData.name;
-        return userGroupData.deleteImage();
-      }
-    })
-    .then((updated) => {
-      req.flash('successMessage', 'Deleted group picture');
-
-      return res.redirect('/admin/user-group-detail/' + userGroupId);
-    })
-    .catch((err) => {
-      debug('An error occured.', err);
-
-      req.flash('errorMessage', 'Error while deleting group picture');
-      if (userGroupName == null) {
-        return res.redirect('/admin/user-groups/');
-      }
-      else {
-        return res.redirect('/admin/user-group-detail/' + userGroupId);
-      }
-    });
-  };
 
   // app.post('/_api/admin/user-group/delete' , admin.userGroup.removeCompletely);
   actions.userGroup.removeCompletely = function(req, res) {
     const id = req.body.user_group_id;
 
-    const fileUploader = require('../service/file-uploader')(crowi, app);
-
     UserGroup.removeCompletelyById(id)
-      //// TODO remove attachments
-      // couldn't remove because filePath includes '/uploads/uploads'
-      // Error: ENOENT: no such file or directory, unlink 'C:\dev\growi\public\uploads\uploads\userGroup\5b1df18ab69611651cc71495.png
-      //
-      // .then(removed => {
-      //   if (removed.image != null) {
-      //     fileUploader.deleteFile(null, removed.image);
-      //   }
-      // })
       .then(() => {
         req.flash('successMessage', '削除しました');
         return res.redirect('/admin/user-groups');

+ 262 - 165
src/server/routes/attachment.js

@@ -1,74 +1,172 @@
+const debug = require('debug')('growi:routss:attachment');
+const logger = require('@alias/logger')('growi:routes:attachment');
+
+const path = require('path');
+const fs = require('fs');
+
+const ApiResponse = require('../util/apiResponse');
+
 module.exports = function(crowi, app) {
-  'use strict';
-
-  var debug = require('debug')('growi:routss:attachment')
-    , logger = require('@alias/logger')('growi:routes:attachment')
-    , Attachment = crowi.model('Attachment')
-    , User = crowi.model('User')
-    , Page = crowi.model('Page')
-    , path = require('path')
-    , fs = require('fs')
-    , fileUploader = require('../service/file-uploader')(crowi, app)
-    , ApiResponse = require('../util/apiResponse')
-    , actions = {}
-    , api = {};
+  const Attachment = crowi.model('Attachment');
+  const User = crowi.model('User');
+  const Page = crowi.model('Page');
+  const fileUploader = require('../service/file-uploader')(crowi, app);
+
+
+  /**
+   * Check the user is accessible to the related page
+   *
+   * @param {User} user
+   * @param {Attachment} attachment
+   */
+  async function isAccessibleByViewer(user, attachment) {
+    if (attachment.page != null) {
+      return await Page.isAccessiblePageByViewer(attachment.page, user);
+    }
+    return true;
+  }
+
+  /**
+   * Check the user is accessible to the related page
+   *
+   * @param {User} user
+   * @param {Attachment} attachment
+   */
+  async function isDeletableByUser(user, attachment) {
+    const ownerId = attachment.creator._id || attachment.creator;
+    if (attachment.page == null) {  // when profile image
+      return user.id === ownerId.toString();
+    }
+    else {
+      return await Page.isAccessiblePageByViewer(attachment.page, user);
+    }
+  }
+
+  /**
+   * Common method to response
+   *
+   * @param {Response} res
+   * @param {User} user
+   * @param {Attachment} attachment
+   * @param {boolean} forceDownload
+   */
+  async function responseForAttachment(res, user, attachment, forceDownload) {
+    if (attachment == null) {
+      return res.json(ApiResponse.error('attachment not found'));
+    }
+
+    const isAccessible = await isAccessibleByViewer(user, attachment);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'`));
+    }
+
+    let fileStream;
+    try {
+      fileStream = await fileUploader.findDeliveryFile(attachment);
+    }
+    catch (e) {
+      logger.error(e);
+      return res.json(ApiResponse.error(e.message));
+    }
+
+    setHeaderToRes(res, attachment, forceDownload);
+    return fileStream.pipe(res);
+  }
+
+  /**
+   * set http response header
+   *
+   * @param {Response} res
+   * @param {Attachment} attachment
+   * @param {boolean} forceDownload
+   */
+  function setHeaderToRes(res, attachment, forceDownload) {
+    // download
+    if (forceDownload) {
+      const headers = {
+        'Content-Type': 'application/force-download',
+        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+      };
+
+      res.writeHead(200, headers);
+    }
+    // reference
+    else {
+      res.set('Content-Type', attachment.fileFormat);
+    }
+  }
+
+  async function createAttachment(file, user, pageId = null) {
+    // check capacity
+    const isUploadable = await fileUploader.checkCapacity(file.size);
+    if (!isUploadable) {
+      throw new Error('File storage reaches limit');
+    }
+
+    const fileStream = fs.createReadStream(file.path, {flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true });
+
+    // create an Attachment document and upload file
+    let attachment;
+    try {
+      attachment = await Attachment.create(pageId, user, fileStream, file.originalname, file.mimetype, file.size);
+    }
+    catch (err) {
+      // delete temporary file
+      fs.unlink(file.path, function(err) { if (err) { logger.error('Error while deleting tmp file.') } });
+      throw err;
+    }
+
+    return attachment;
+  }
+
+
+  const actions = {};
+  const api = {};
 
   actions.api = api;
 
-  api.download = function(req, res) {
+  api.download = async function(req, res) {
     const id = req.params.id;
 
-    Attachment.findById(id)
-      .then(function(data) {
-
-        Attachment.findDeliveryFile(data)
-          .then(fileName => {
-
-            // local
-            if (fileName.match(/^\/uploads/)) {
-              return res.download(path.join(crowi.publicDir, fileName), data.originalName);
-            }
-            // aws or gridfs
-            else {
-              const options = {
-                headers: {
-                  'Content-Type': 'application/force-download',
-                  'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(data.originalName)}`,
-                }
-              };
-              return res.sendFile(fileName, options);
-            }
-          });
-      })
-      // not found
-      .catch((err) => {
-        logger.error('download err', err);
-        return res.status(404).sendFile(crowi.publicDir + '/images/file-not-found.png');
-      });
+    const attachment = await Attachment.findById(id);
+
+    return responseForAttachment(res, req.user, attachment, true);
   };
 
   /**
-   * @api {get} /attachments.get get attachments from mongoDB
+   * @api {get} /attachments.get get attachments
    * @apiName get
    * @apiGroup Attachment
    *
-   * @apiParam {String} pageId, fileName
+   * @apiParam {String} id
    */
   api.get = async function(req, res) {
+    const id = req.params.id;
+
+    const attachment = await Attachment.findById(id);
+
+    return responseForAttachment(res, req.user, attachment);
+  };
+
+  /**
+   * @api {get} /attachments.obsoletedGetForMongoDB get attachments from mongoDB
+   * @apiName get
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} pageId, fileName
+   */
+  api.obsoletedGetForMongoDB = async function(req, res) {
     if (process.env.FILE_UPLOAD !== 'mongodb') {
       return res.status(400);
     }
+
     const pageId = req.params.pageId;
     const fileName = req.params.fileName;
     const filePath = `attachment/${pageId}/${fileName}`;
-    try {
-      const fileData = await fileUploader.getFileData(filePath);
-      res.set('Content-Type', fileData.contentType);
-      return res.send(ApiResponse.success(fileData.data));
-    }
-    catch (e) {
-      return res.json(ApiResponse.error('attachment not found'));
-    }
+
+    const attachment = await Attachment.findOne({ filePath });
+
+    return responseForAttachment(res, req.user, attachment);
   };
 
   /**
@@ -78,31 +176,19 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} page_id
    */
-  api.list = function(req, res) {
+  api.list = async function(req, res) {
     const id = req.query.page_id || null;
     if (!id) {
       return res.json(ApiResponse.error('Parameters page_id is required.'));
     }
 
-    Attachment.getListByPageId(id)
-    .then(function(attachments) {
-
-      // NOTE: use original fileUrl directly (not proxy) -- 2017.05.08 Yuki Takei
-      // reason:
-      //   1. this is buggy (doesn't work on Win)
-      //   2. ensure backward compatibility of data
-
-      // var baseUrl = crowi.configManager.getSiteUrl();
-      return res.json(ApiResponse.success({
-        attachments: attachments.map(at => {
-          const fileUrl = at.fileUrl;
-          at = at.toObject();
-          // at.url = baseUrl + fileUrl;
-          at.url = fileUrl;
-          return at;
-        })
-      }));
-    });
+    let attachments = await Attachment.find({page: id})
+      .sort({'updatedAt': 1})
+      .populate({ path: 'creator', select: User.USER_PUBLIC_FIELDS, populate: User.IMAGE_POPULATION });
+
+    attachments = attachments.map(attachment => attachment.toObject({ virtuals: true }));
+
+    return res.json(ApiResponse.success({ attachments }));
   };
 
   /**
@@ -124,88 +210,96 @@ module.exports = function(crowi, app) {
    * @apiParam {File} file
    */
   api.add = async function(req, res) {
-    var id = req.body.page_id || 0,
-      path = decodeURIComponent(req.body.path) || null,
-      pageCreated = false,
-      page = {};
-
-    debug('id and path are: ', id, path);
+    let pageId = req.body.page_id || null;
+    const pagePath = decodeURIComponent(req.body.path) || null;
+    let pageCreated = false;
 
-    var tmpFile = req.file || null;
-    const isUploadable = await fileUploader.checkCapacity(tmpFile.size);
-    if (!isUploadable) {
-      return res.json(ApiResponse.error('MongoDB for uploading files reaches limit'));
+    // check params
+    if (pageId == null && pagePath == null) {
+      return res.json(ApiResponse.error('Either page_id or path is required.'));
     }
-    debug('Uploaded tmpFile: ', tmpFile);
-    if (!tmpFile) {
+    if (!req.file) {
       return res.json(ApiResponse.error('File error.'));
     }
-    new Promise(function(resolve, reject) {
-      if (id == 0) {
-        if (path === null) {
-          throw new Error('path required if page_id is not specified.');
-        }
-        debug('Create page before file upload');
-        Page.create(path, '# '  + path, req.user, {grant: Page.GRANT_OWNER})
-          .then(function(page) {
-            pageCreated = true;
-            resolve(page);
-          })
-          .catch(reject);
-      }
-      else {
-        Page.findById(id).then(resolve).catch(reject);
+
+    const file = req.file;
+
+    let page;
+    if (pageId == null) {
+      logger.debug('Create page before file upload');
+
+      page = await Page.create(path, '# '  + path, req.user, {grant: Page.GRANT_OWNER});
+      pageCreated = true;
+      pageId = page._id;
+    }
+    else {
+      page = await Page.findById(pageId);
+
+      // check the user is accessible
+      const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
+      if (!isAccessible) {
+        return res.json(ApiResponse.error(`Forbidden to access to the page '${page.id}'`));
       }
-    }).then(function(pageData) {
-      page = pageData;
-      id = pageData._id;
-
-      var tmpPath = tmpFile.path,
-        originalName = tmpFile.originalname,
-        fileName = tmpFile.filename + tmpFile.originalname,
-        fileType = tmpFile.mimetype,
-        fileSize = tmpFile.size,
-        filePath = Attachment.createAttachmentFilePath(id, fileName, fileType),
-        tmpFileStream = fs.createReadStream(tmpPath, {flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true });
-
-      return fileUploader.uploadFile(filePath, fileType, tmpFileStream, {})
-        .then(function(data) {
-          debug('Uploaded data is: ', data);
-
-          // TODO size
-          return Attachment.create(id, req.user, filePath, originalName, fileName, fileType, fileSize);
-        }).then(function(data) {
-          var fileUrl = data.fileUrl;
-
-          var result = {
-            page: page.toObject(),
-            attachment: data.toObject(),
-            url: fileUrl,
-            pageCreated: pageCreated,
-          };
-
-          result.page.creator = User.filterToPublicFields(result.page.creator);
-          result.attachment.creator = User.filterToPublicFields(result.attachment.creator);
-
-          // delete anyway
-          fs.unlink(tmpPath, function(err) { if (err) { debug('Error while deleting tmp file.') } });
-
-          return res.json(ApiResponse.success(result));
-        }).catch(function(err) {
-          logger.error('Error on saving attachment data', err);
-          // @TODO
-          // Remove from S3
-
-          // delete anyway
-          fs.unlink(tmpPath, function(err) { if (err) { logger.error('Error while deleting tmp file.') } });
-
-          return res.json(ApiResponse.error('Error while uploading.'));
-        });
-
-    }).catch(function(err) {
-      logger.error('Attachement upload error', err);
-      return res.json(ApiResponse.error('Error.'));
-    });
+    }
+
+    let attachment;
+    try {
+      attachment = await createAttachment(file, req.user, pageId);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error(err.message));
+    }
+
+    const result = {
+      page: page.toObject(),
+      attachment: attachment.toObject({ virtuals: true }),
+      pageCreated: pageCreated,
+    };
+
+    return res.json(ApiResponse.success(result));
+  };
+
+  /**
+   * @api {post} /attachments.uploadProfileImage Add attachment for profile image
+   * @apiName UploadProfileImage
+   * @apiGroup Attachment
+   *
+   * @apiParam {File} file
+   */
+  api.uploadProfileImage = async function(req, res) {
+    // check params
+    if (!req.file) {
+      return res.json(ApiResponse.error('File error.'));
+    }
+    if (!req.user) {
+      return res.json(ApiResponse.error('param "user" must be set.'));
+    }
+
+    const file = req.file;
+
+    // check type
+    const acceptableFileType = /image\/.+/;
+    if (!file.mimetype.match(acceptableFileType)) {
+      return res.json(ApiResponse.error('File type error. Only image files is allowed to set as user picture.'));
+    }
+
+    let attachment;
+    try {
+      req.user.deleteImage();
+      attachment = await createAttachment(file, req.user);
+      await req.user.updateImage(attachment);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error(err.message));
+    }
+
+    const result = {
+      attachment: attachment.toObject({ virtuals: true }),
+    };
+
+    return res.json(ApiResponse.success(result));
   };
 
   /**
@@ -215,25 +309,28 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} attachment_id
    */
-  api.remove = function(req, res) {
+  api.remove = async function(req, res) {
     const id = req.body.attachment_id;
 
-    Attachment.findById(id)
-    .then(function(data) {
-      const attachment = data;
-
-      Attachment.removeAttachment(attachment)
-      .then(data => {
-        debug('removeAttachment', data);
-        return res.json(ApiResponse.success({}));
-      }).catch(err => {
-        logger.error('Error', err);
-        return res.status(500).json(ApiResponse.error('Error while deleting file'));
-      });
-    }).catch(err => {
-      logger.error('Error', err);
-      return res.status(404);
-    });
+    const attachment = await Attachment.findById(id);
+
+    if (attachment == null) {
+      return res.json(ApiResponse.error('attachment not found'));
+    }
+
+    const isDeletable = await isDeletableByUser(req.user, attachment);
+    if (!isDeletable) {
+      return res.json(ApiResponse.error(`Forbidden to remove the attachment '${attachment.id}'`));
+    }
+
+    try {
+      await Attachment.removeWithSubstanceById(id);
+    }
+    catch (err) {
+      return res.status(500).json(ApiResponse.error('Error while deleting file'));
+    }
+
+    return res.json(ApiResponse.success({}));
   };
 
   return actions;

+ 7 - 3
src/server/routes/comment.js

@@ -31,20 +31,24 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
-    let comments = null;
+    let fetcher = null;
 
     try {
       if (revisionId) {
-        comments = await Comment.getCommentsByRevisionId(revisionId);
+        fetcher = Comment.getCommentsByRevisionId(revisionId);
       }
       else {
-        comments = await Comment.getCommentsByPageId(pageId);
+        fetcher = Comment.getCommentsByPageId(pageId);
       }
     }
     catch (err) {
       return res.json(ApiResponse.error(err));
     }
 
+    const comments = await fetcher.populate(
+      { path: 'creator', select: User.USER_PUBLIC_FIELDS, populate: User.IMAGE_POPULATION }
+    );
+
     res.json(ApiResponse.success({comments}));
   };
 

+ 10 - 9
src/server/routes/index.js

@@ -1,6 +1,9 @@
+const multer = require('multer')
+const autoReap  = require('multer-autoreap');
+autoReap.options.reapOnError = true;  // continue reaping the file even if an error occurs
+
 module.exports = function(crowi, app) {
   const middleware = require('../util/middlewares')
-    , multer    = require('multer')
     , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , form      = require('../form')
     , page      = require('./page')(crowi, app)
@@ -140,9 +143,7 @@ module.exports = function(crowi, app) {
   app.get('/admin/user-group-detail/:id'          , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.detail);
   app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.create);
   app.post('/admin/user-group/:userGroupId/update', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.update);
-  app.post('/admin/user-group/:userGroupId/picture/delete', loginRequired(crowi, app), admin.userGroup.deletePicture);
   app.post('/admin/user-group.remove' , loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.removeCompletely);
-  app.post('/_api/admin/user-group/:userGroupId/picture/upload', loginRequired(crowi, app), uploads.single('userGroupPicture'), admin.userGroup.uploadGroupPicture);
 
   // user-group-relations admin
   app.post('/admin/user-group-relation/create', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.create);
@@ -170,20 +171,19 @@ module.exports = function(crowi, app) {
   app.post('/me/password'             , form.me.password          , loginRequired(crowi, app) , me.password);
   app.post('/me/imagetype'            , form.me.imagetype         , loginRequired(crowi, app) , me.imagetype);
   app.post('/me/apiToken'             , form.me.apiToken          , loginRequired(crowi, app) , me.apiToken);
-  app.post('/me/picture/delete'       , loginRequired(crowi, app) , me.deletePicture);
   app.post('/me/auth/google'          , loginRequired(crowi, app) , me.authGoogle);
   app.get( '/me/auth/google/callback' , loginRequired(crowi, app) , me.authGoogleCallback);
 
   app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app, false) , page.redirector);
   app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false) , page.redirector); // alias
-  app.get( '/download/:id([0-9a-z]{24})' , loginRequired(crowi, app, false) , attachment.api.download);
-  app.get( '/attachment/:pageId/:fileName'  , loginRequired(crowi, app, false), attachment.api.get);
+  app.get( '/attachment/:pageId/:fileName'  , loginRequired(crowi, app, false), attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  app.get( '/attachment/:id([0-9a-z]{24})'  , loginRequired(crowi, app, false), attachment.api.get);
+  app.get( '/download/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false), attachment.api.download);
 
   app.get( '/_search'                 , loginRequired(crowi, app, false) , search.searchPage);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);
 
   app.get( '/_api/check_username'           , user.api.checkUsername);
-  app.post('/_api/me/picture/upload'        , loginRequired(crowi, app) , uploads.single('userPicture'), me.api.uploadPicture);
   app.get( '/_api/me/user-group-relations'  , accessTokenParser , loginRequired(crowi, app) , me.api.userGroupRelations);
   app.get( '/_api/user/bookmarks'           , loginRequired(crowi, app, false) , user.api.bookmarks);
 
@@ -211,9 +211,10 @@ module.exports = function(crowi, app) {
   app.post('/_api/likes.add'          , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.like);
   app.post('/_api/likes.remove'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.unlike);
   app.get( '/_api/attachments.list'   , accessTokenParser , loginRequired(crowi, app, false) , attachment.api.list);
-  app.post('/_api/attachments.add'    , uploads.single('file'), accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.add);
+  app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.add);
+  app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.uploadProfileImage);
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
-  app.get( '/_api/attachments.limit' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.limit);
+  app.get( '/_api/attachments.limit'  , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.limit);
 
   app.get( '/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.get);
   app.get( '/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.ids);

+ 0 - 31
src/server/routes/login.js

@@ -235,37 +235,6 @@ module.exports = function(crowi, app) {
                 }
                 return loginSuccess(req, res, userData);
               });
-
-              if (googleImage) {
-                var axios = require('axios');
-                var fileUploader = require('../service/file-uploader')(crowi, app);
-                var filePath = User.createUserPictureFilePath(
-                  userData,
-                  googleImage.replace(/^.+\/(.+\..+)$/, '$1')
-                );
-
-                axios.get(googleImage, {responseType: 'stream'})
-                .then(function(response) {
-                  var type = response.headers['content-type'];
-                  var fileStream = response.data;
-                  fileStream.length = parseInt(response.headers['content-length']);
-
-                  fileUploader.uploadFile(filePath, type, fileStream, {})
-                  .then(function(data) {
-                    var imageUrl = fileUploader.generateUrl(filePath);
-                    debug('user picture uploaded', imageUrl);
-                    userData.updateImage(imageUrl, function(err, data) {
-                      if (err) {
-                        debug('Error on update user image', err);
-                      }
-                      // DONE
-                    });
-                  }).catch(function(err) { // ignore
-                    debug('Upload error', err);
-                  });
-                }).catch(function() { // ignore
-                });
-              }
             }
             else {
               // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei

+ 0 - 68
src/server/routes/me.js

@@ -16,66 +16,6 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
-  api.uploadPicture = function(req, res) {
-    var fileUploader = require('../service/file-uploader')(crowi, app);
-    //var storagePlugin = new pluginService('storage');
-    //var storage = require('../service/storage').StorageService(config);
-
-    var tmpFile = req.file || null;
-    if (!tmpFile) {
-      return res.json({
-        'status': false,
-        'message': 'File type error.'
-      });
-    }
-
-    var tmpPath = tmpFile.path;
-    var filePath = User.createUserPictureFilePath(req.user, tmpFile.filename + tmpFile.originalname);
-    var acceptableFileType = /image\/.+/;
-
-    if (!tmpFile.mimetype.match(acceptableFileType)) {
-      return res.json({
-        'status': false,
-        'message': 'File type error. Only image files is allowed to set as user picture.',
-      });
-    }
-
-    //debug('tmpFile Is', tmpFile, tmpFile.constructor, tmpFile.prototype);
-    //var imageUrl = storage.writeSync(storage.tofs(tmpFile), filePath, {mime: tmpFile.mimetype});
-    //return return res.json({
-    //  'status': true,
-    //  'url': imageUrl,
-    //  'message': '',
-    //});
-    var tmpFileStream = fs.createReadStream(tmpPath, {flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true });
-
-    fileUploader.uploadFile(filePath, tmpFile.mimetype, tmpFileStream, {})
-    .then(function(data) {
-      var imageUrl = fileUploader.generateUrl(filePath);
-      req.user.updateImage(imageUrl, function(err, data) {
-        fs.unlink(tmpPath, function(err) {
-          // エラー自体は無視
-          if (err) {
-            debug('Error while deleting tmp file.', err);
-          }
-
-          return res.json({
-            'status': true,
-            'url': imageUrl,
-            'message': '',
-          });
-        });
-      });
-    }).catch(function(err) {
-      debug('Uploading error', err);
-
-      return res.json({
-        'status': false,
-        'message': 'Error while uploading to ',
-      });
-    });
-  };
-
   /**
    * retrieve user-group-relation documents
    * @param {object} req
@@ -375,14 +315,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.deletePicture = function(req, res) {
-    // TODO: S3 からの削除
-    req.user.deleteImage(function(err, data) {
-      req.flash('successMessage', 'Deleted profile picture');
-      res.redirect('/me');
-    });
-  };
-
   actions.authGoogle = function(req, res) {
     var googleAuth = require('../util/googleAuth')(crowi);
 

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

@@ -116,7 +116,9 @@ module.exports = function(crowi, app) {
   }
 
   async function addRenderVarsForUserPage(renderVars, page, requestUser) {
-    const userData = await User.findUserByUsername(User.getUsernameByPath(page.path));
+    const userData = await User.findUserByUsername(User.getUsernameByPath(page.path))
+      .populate(User.IMAGE_POPULATION);
+
     if (userData != null) {
       renderVars.pageUser = userData;
       renderVars.bookmarkList = await Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: requestUser});

+ 13 - 16
src/server/routes/user.js

@@ -44,10 +44,10 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} user_ids
    */
-  api.list = function(req, res) {
-    var userIds = req.query.user_ids || null; // TODO: handling
+  api.list = async function(req, res) {
+    const userIds = req.query.user_ids || null; // TODO: handling
 
-    var userFetcher;
+    let userFetcher;
     if (!userIds || userIds.split(',').length <= 0) {
       userFetcher = User.findAllUsers();
     }
@@ -55,25 +55,22 @@ module.exports = function(crowi, app) {
       userFetcher = User.findUsersByIds(userIds.split(','));
     }
 
-    userFetcher
-    .then(function(userList) {
-      return userList.map((user) => {
+    const data = {};
+    try {
+      const users = await userFetcher.populate(User.IMAGE_POPULATION);
+      data.users = users.map(user => {
         // omit email
         if (true !== user.isEmailPublished) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
           user.email = undefined;
         }
-        return user;
+        return user.toObject({ virtuals: true });
       });
-    })
-    .then(function(userList) {
-      var result = {
-        users: userList,
-      };
-
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
+    }
+    catch (err) {
       return res.json(ApiResponse.error(err));
-    });
+    }
+
+    return res.json(ApiResponse.success(data));
   };
 
   return actions;

+ 66 - 118
src/server/service/file-uploader/aws.js

@@ -1,22 +1,22 @@
-// crowi-fileupload-aws
+const logger = require('@alias/logger')('growi:service:fileUploaderAws');
+
+const axios = require('axios');
+const urljoin = require('url-join');
+const aws = require('aws-sdk');
 
 module.exports = function(crowi) {
-  'use strict';
-
-  var aws = require('aws-sdk')
-    , fs = require('fs')
-    , path = require('path')
-    , debug = require('debug')('growi:service:fileUploaderAws')
-    , lib = {}
-    , getAwsConfig = function() {
-      var config = crowi.getConfig();
-      return {
-        accessKeyId: config.crowi['aws:accessKeyId'],
-        secretAccessKey: config.crowi['aws:secretAccessKey'],
-        region: config.crowi['aws:region'],
-        bucket: config.crowi['aws:bucket']
-      };
+
+  const lib = {};
+
+  function getAwsConfig() {
+    const config = crowi.getConfig();
+    return {
+      accessKeyId: config.crowi['aws:accessKeyId'],
+      secretAccessKey: config.crowi['aws:secretAccessKey'],
+      region: config.crowi['aws:region'],
+      bucket: config.crowi['aws:bucket']
     };
+  }
 
   function S3Factory() {
     const awsConfig = getAwsConfig();
@@ -36,129 +36,77 @@ module.exports = function(crowi) {
     return new aws.S3();
   }
 
-  lib.deleteFile = function(fileId, filePath) {
-    const s3 = S3Factory();
-    const awsConfig = getAwsConfig();
-
-    const params = {
-      Bucket: awsConfig.bucket,
-      Key: filePath,
-    };
+  function getFilePathOnStorage(attachment) {
+    if (attachment.filePath != null) {  // backward compatibility for v3.3.x or below
+      return attachment.filePath;
+    }
 
-    return new Promise((resolve, reject) => {
-      s3.deleteObject(params, (err, data) => {
-        if (err) {
-          debug('Failed to delete object from s3', err);
-          return reject(err);
-        }
+    const dirName = (attachment.page != null)
+      ? 'attachment'
+      : 'user';
+    const filePath = urljoin(dirName, attachment.fileName);
 
-        // asynclonousely delete cache
-        lib.clearCache(fileId);
+    return filePath;
+  }
 
-        resolve(data);
-      });
-    });
+  lib.deleteFile = async function(attachment) {
+    const filePath = getFilePathOnStorage(attachment);
+    return lib.deleteFileByFilePath(filePath);
   };
 
-  lib.uploadFile = function(filePath, contentType, fileStream, options) {
+  lib.deleteFileByFilePath = async function(filePath) {
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
 
-    var params = {Bucket: awsConfig.bucket};
-    params.ContentType = contentType;
-    params.Key = filePath;
-    params.Body = fileStream;
-    params.ACL = 'public-read';
-
-    return new Promise(function(resolve, reject) {
-      s3.putObject(params, function(err, data) {
-        if (err) {
-          return reject(err);
-        }
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
+    };
 
-        return resolve(data);
-      });
-    });
+    return s3.deleteObject(params).promise();
   };
 
-  lib.generateUrl = function(filePath) {
-    var awsConfig = getAwsConfig()
-      , url = 'https://' + awsConfig.bucket +'.s3.amazonaws.com/' + filePath;
+  lib.uploadFile = function(fileStream, attachment) {
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-    return url;
-  };
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
 
-  lib.findDeliveryFile = function(fileId, filePath) {
-    var cacheFile = lib.createCacheFileName(fileId);
-
-    return new Promise((resolve, reject) => {
-      debug('find delivery file', cacheFile);
-      if (!lib.shouldUpdateCacheFile(cacheFile)) {
-        return resolve(cacheFile);
-      }
-
-      var loader = require('https');
-
-      var fileStream = fs.createWriteStream(cacheFile);
-      var fileUrl = lib.generateUrl(filePath);
-      debug('Load attachement file into local cache file', fileUrl, cacheFile);
-      loader.get(fileUrl, function(response) {
-        response.pipe(fileStream, { end: false });
-        response.on('end', () => {
-          fileStream.end();
-          resolve(cacheFile);
-        });
-      });
-    });
-  };
+    const filePath = getFilePathOnStorage(attachment);
+    const params = {
+      Bucket: awsConfig.bucket,
+      ContentType: attachment.fileFormat,
+      Key: filePath,
+      Body: fileStream,
+      ACL: 'public-read',
+    };
 
-  lib.clearCache = function(fileId) {
-    const cacheFile = lib.createCacheFileName(fileId);
-
-    (new Promise((resolve, reject) => {
-      fs.unlink(cacheFile, (err) => {
-        if (err) {
-          debug('Failed to delete cache file', err);
-          // through
-        }
-
-        resolve();
-      });
-    })).then(data => {
-      // success
-    }).catch(err => {
-      debug('Failed to delete cache file (file may not exists).', err);
-      // through
-    });
+    return s3.upload(params).promise();
   };
 
-  // private
-  lib.createCacheFileName = function(fileId) {
-    return path.join(crowi.cacheDir, `attachment-${fileId}`);
-  };
+  /**
+   * Find data substance
+   *
+   * @param {Attachment} attachment
+   * @return {stream.Readable} readable stream
+   */
+  lib.findDeliveryFile = async function(attachment) {
+    // construct url
+    const awsConfig = getAwsConfig();
+    const baseUrl = `https://${awsConfig.bucket}.s3.amazonaws.com`;
+    const url = urljoin(baseUrl, getFilePathOnStorage(attachment));
 
-  // private
-  lib.shouldUpdateCacheFile = function(filePath) {
+    let response;
     try {
-      var stats = fs.statSync(filePath);
-
-      if (!stats.isFile()) {
-        debug('Cache file not found or the file is not a regular fil.');
-        return true;
-      }
-
-      if (stats.size <= 0) {
-        debug('Cache file found but the size is 0');
-        return true;
-      }
+      response = await axios.get(url, { responseType: 'stream' });
     }
-    catch (e) {
-      // no such file or directory
-      debug('Stats error', e);
-      return true;
+    catch (err) {
+      logger.error(err);
+      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
     }
 
-    return false;
+    // return stream.Readable
+    return response.data;
   };
 
   /**

+ 33 - 117
src/server/service/file-uploader/gridfs.js

@@ -1,12 +1,10 @@
-// crowi-fileupload-gridFS
+const logger = require('@alias/logger')('growi:service:fileUploaderGridfs');
+const mongoose = require('mongoose');
+const util = require('util');
 
 module.exports = function(crowi) {
   'use strict';
 
-  const debug = require('debug')('growi:service:fileUploaderGridfs');
-  const mongoose = require('mongoose');
-  const path = require('path');
-  const fs = require('fs');
   const lib = {};
 
   // instantiate mongoose-gridfs
@@ -20,29 +18,17 @@ module.exports = function(crowi) {
   const AttachmentFile = gridfs.model;
   const Chunks = mongoose.model('Chunks', gridfs.schema, 'attachmentFiles.chunks');
 
-  // delete a file
-  lib.deleteFile = async function(fileId, filePath) {
-    debug('File deletion: ' + fileId);
-    const file = await getFile(filePath);
-    const id = file.id;
-    AttachmentFile.unlinkById(id, function(error, unlinkedAttachment) {
+  // create promisified method
+  AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
+
+  lib.deleteFile = async function(attachment) {
+    const attachmentFile = await AttachmentFile.findOne({ filename: attachment.fileName });
+
+    AttachmentFile.unlinkById(attachmentFile._id, function(error, unlinkedFile) {
       if (error) {
         throw new Error(error);
       }
     });
-    clearCache(fileId);
-  };
-
-  const clearCache = (fileId) => {
-    const cacheFile = createCacheFileName(fileId);
-    const stats = fs.statSync(crowi.cacheDir);
-    if (stats.isFile(`attachment-${fileId}`)) {
-      fs.unlink(cacheFile, (err) => {
-        if (err) {
-          throw new Error('fail to delete cache file', err);
-        }
-      });
-    }
   };
 
   /**
@@ -72,108 +58,38 @@ module.exports = function(crowi) {
     return (+process.env.MONGO_GRIDFS_TOTAL_LIMIT > usingFilesSize + +uploadFileSize);
   };
 
-  lib.uploadFile = async function(filePath, contentType, fileStream, options) {
-    debug('File uploading: ' + filePath);
-    await writeFile(filePath, contentType, fileStream);
-  };
-
-  /**
-   * write file to MongoDB with GridFS (Promise wrapper)
-   */
-  const writeFile = (filePath, contentType, fileStream) => {
-    return new Promise((resolve, reject) => {
-      AttachmentFile.write({
-        filename: filePath,
-        contentType: contentType
-      }, fileStream,
-      function(error, createdFile) {
-        if (error) {
-          reject(error);
-        }
-        resolve();
-      });
-    });
-  };
-
-  lib.getFileData = async function(filePath) {
-    const file = await getFile(filePath);
-    const id = file._id;
-    const contentType = file.contentType;
-    const data = await readFileData(id);
-    return {
-      data,
-      contentType
-    };
-  };
+  lib.uploadFile = async function(fileStream, attachment) {
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-  /**
-   * get file from MongoDB (Promise wrapper)
-   */
-  const getFile = (filePath) => {
-    return new Promise((resolve, reject) => {
-      AttachmentFile.findOne({
-        filename: filePath
-      }, function(err, file) {
-        if (err) {
-          reject(err);
-        }
-        resolve(file);
-      });
-    });
+    return AttachmentFile.promisifiedWrite(
+      {
+        filename: attachment.fileName,
+        contentType: attachment.fileFormat
+      },
+      fileStream);
   };
 
   /**
-   * read File in MongoDB (Promise wrapper)
+   * Find data substance
+   *
+   * @param {Attachment} attachment
+   * @return {stream.Readable} readable stream
    */
-  const readFileData = (id) => {
-    return new Promise((resolve, reject) => {
-      let buf;
-      const stream = AttachmentFile.readById(id);
-      stream.on('error', function(error) {
-        reject(error);
-      });
-      stream.on('data', function(data) {
-        if (buf) {
-          buf = Buffer.concat([buf, data]);
-        }
-        else {
-          buf = data;
-        }
-      });
-      stream.on('close', function() {
-        debug('GridFS readstream closed');
-        resolve(buf);
-      });
-    });
-  };
+  lib.findDeliveryFile = async function(attachment) {
+    let filenameValue = attachment.fileName;
 
-  lib.findDeliveryFile = async function(fileId, filePath) {
-    const cacheFile = createCacheFileName(fileId);
-    debug('Load attachement file into local cache file', cacheFile);
-    const fileStream = fs.createWriteStream(cacheFile);
-    const file = await getFile(filePath);
-    const id = file.id;
-    const buf = await readFileData(id);
-    await writeCacheFile(fileStream, buf);
-    return cacheFile;
-  };
+    if (attachment.filePath != null) {  // backward compatibility for v3.3.x or below
+      filenameValue = attachment.filePath;
+    }
 
-  const createCacheFileName = (fileId) => {
-    return path.join(crowi.cacheDir, `attachment-${fileId}`);
-  };
+    const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
 
-  /**
-   * write cache file (Promise wrapper)
-   */
-  const writeCacheFile = (fileStream, data) => {
-    return new Promise((resolve, reject) => {
-      fileStream.write(data);
-      resolve();
-    });
-  };
+    if (attachmentFile == null) {
+      throw new Error(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
+    }
 
-  lib.generateUrl = function(filePath) {
-    return `/${filePath}`;
+    // return stream.Readable
+    return AttachmentFile.readById(attachmentFile._id);
   };
 
   return lib;

+ 2 - 0
src/server/service/file-uploader/index.js

@@ -2,7 +2,9 @@ const envToModuleMappings = {
   aws:     'aws',
   local:   'local',
   none:    'none',
+  mongo:   'gridfs',
   mongodb: 'gridfs',
+  gridfs:  'gridfs',
 };
 
 class FileUploaderFactory {

+ 56 - 42
src/server/service/file-uploader/local.js

@@ -1,58 +1,72 @@
-// crowi-fileupload-local
+const logger = require('@alias/logger')('growi:service:fileUploaderLocal');
+
+const fs = require('fs');
+const path = require('path');
+const mkdir = require('mkdirp');
+const streamToPromise = require('stream-to-promise');
 
 module.exports = function(crowi) {
   'use strict';
 
-  var debug = require('debug')('growi:service:fileUploaderLocal')
-    , fs = require('fs')
-    , path = require('path')
-    , mkdir = require('mkdirp')
-    , lib = {}
-    , basePath = path.posix.join(crowi.publicDir, 'uploads'); // TODO: to configurable
-
-  lib.deleteFile = function(fileId, filePath) {
-    debug('File deletion: ' + filePath);
-    return new Promise(function(resolve, reject) {
-      fs.unlink(path.posix.join(basePath, filePath), function(err) {
-        if (err) {
-          return reject(err);
-        }
-
-        resolve();
-      });
-    });
+  const lib = {};
+  const basePath = path.posix.join(crowi.publicDir, 'uploads');
+
+  function getFilePathOnStorage(attachment) {
+    let filePath;
+    if (attachment.filePath != null) {  // backward compatibility for v3.3.x or below
+      filePath = path.posix.join(basePath, attachment.filePath);
+    }
+    else {
+      const dirName = (attachment.page != null)
+        ? 'attachment'
+        : 'user';
+      filePath = path.posix.join(basePath, dirName, attachment.fileName);
+    }
+
+    return filePath;
+  }
+
+  lib.deleteFile = async function(attachment) {
+    const filePath = getFilePathOnStorage(attachment);
+    return lib.deleteFileByFilePath(filePath);
   };
 
-  lib.uploadFile = function(filePath, contentType, fileStream, options) {
-    debug('File uploading: ' + filePath);
-    return new Promise(function(resolve, reject) {
-      var localFilePath = path.posix.join(basePath, filePath)
-        , dirpath = path.posix.dirname(localFilePath);
+  lib.deleteFileByFilePath = async function(filePath) {
+    return fs.unlinkSync(filePath);
+  };
 
-      mkdir(dirpath, function(err) {
-        if (err) {
-          return reject(err);
-        }
+  lib.uploadFile = async function(fileStream, attachment) {
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-        var writer = fs.createWriteStream(localFilePath);
+    const filePath = getFilePathOnStorage(attachment);
+    const dirpath = path.posix.dirname(filePath);
 
-        writer.on('error', function(err) {
-          reject(err);
-        }).on('finish', function() {
-          resolve();
-        });
+    // mkdir -p
+    mkdir.sync(dirpath);
 
-        fileStream.pipe(writer);
-      });
-    });
+    const stream = fileStream.pipe(fs.createWriteStream(filePath));
+    return streamToPromise(stream);
   };
 
-  lib.generateUrl = function(filePath) {
-    return path.posix.join('/uploads', filePath);
-  };
+  /**
+   * Find data substance
+   *
+   * @param {Attachment} attachment
+   * @return {stream.Readable} readable stream
+   */
+  lib.findDeliveryFile = async function(attachment) {
+    const filePath = getFilePathOnStorage(attachment);
+
+    // check file exists
+    try {
+      fs.statSync(filePath);
+    }
+    catch (err) {
+      throw new Error(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in local fs`);
+    }
 
-  lib.findDeliveryFile = function(fileId, filePath) {
-    return Promise.resolve(lib.generateUrl(filePath));
+    // return stream.Readable
+    return fs.createReadStream(filePath);
   };
 
   /**

+ 11 - 4
src/server/service/passport.js

@@ -511,10 +511,17 @@ class PassportService {
     passport.serializeUser(function(user, done) {
       done(null, user.id);
     });
-    passport.deserializeUser(function(id, done) {
-      User.findById(id, function(err, user) {
-        done(err, user);
-      });
+    passport.deserializeUser(async function(id, done) {
+      try {
+        const user = await User.findById(id).populate(User.IMAGE_POPULATION);
+        if (user == null) {
+          throw new Error('user not found');
+        }
+        done(null, user);
+      }
+      catch (err) {
+        done(err);
+      }
     });
 
     this.isSerializerSetup = true;

+ 18 - 18
src/server/util/middlewares.js

@@ -1,4 +1,5 @@
 const debug = require('debug')('growi:lib:middlewares');
+const logger = require('@alias/logger')('growi:lib:middlewares');
 const md5 = require('md5');
 const entities = require('entities');
 
@@ -15,27 +16,23 @@ exports.csrfKeyGenerator = function(crowi, app) {
 };
 
 exports.loginChecker = function(crowi, app) {
-  return function(req, res, next) {
-    var User = crowi.model('User');
+  const User = crowi.model('User');
+  return async function(req, res, next) {
+    let user = null;
+
+    try {
+      // session に user object が入ってる
+      if (req.session.user && '_id' in req.session.user) {
+        user = await User.findById(req.session.user._id).populate(User.IMAGE_POPULATION);
+      }
 
-    // session に user object が入ってる
-    if (req.session.user && '_id' in req.session.user) {
-      User.findById(req.session.user._id, function(err, userData) {
-        if (err) {
-          next();
-        }
-        else {
-          req.user = req.session.user = userData;
-          res.locals.user = req.user;
-          next();
-        }
-      });
-    }
-    else {
-      req.user = req.session.user = null;
+      req.user = req.session.user = user;
       res.locals.user = req.user;
       next();
     }
+    catch (err) {
+      next(err);
+    }
   };
 };
 
@@ -62,7 +59,7 @@ exports.csrfVerify = function(crowi, app) {
       return next();
     }
 
-    debug('csrf verification failed. return 403', csrfKey, token);
+    logger.warn('csrf verification failed. return 403', csrfKey, token);
     return res.sendStatus(403);
   };
 };
@@ -88,6 +85,9 @@ exports.swigFilters = function(crowi, app, swig) {
     if (user.image) {
       return user.image;
     }
+    else if (user.imageAttachment != null) {
+      return user.imageAttachment.filePathProxied;
+    }
     else {
       return '/images/icons/user.svg';
     }

+ 0 - 49
src/server/views/admin/user-group-detail.html

@@ -114,55 +114,6 @@
         </form>
       </div>
 
-      <div class="m-t-20 form-box">
-        <fieldset>
-          <legend>グループ画像の設定</legend>
-          <div class="form-group col-sm-8">
-            <h4>
-              {{ t('Upload Image') }}
-            </h4>
-            <div class="form-group">
-              <div id="pictureUploadFormMessage"></div>
-              <label for="" class="col-sm-4 control-label">
-                {{ t('Current Image') }}
-              </label>
-              <div class="col-sm-8">
-                <p>
-                  <img src="{{ userGroup|uploadedpicture }}" id="settingUserPicture" class="picture picture-lg img-circle">
-                  <br>
-                </p>
-                <p>
-                  {% if userGroup.image %}
-                  <form action="/admin/user-group/{{userGroup.id}}/picture/delete" method="post" class="form-horizontal" role="form" onsubmit="return window.confirm('{{ t('Delete this image?') }}');">
-                    <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
-                  </form>
-                  {% endif %}
-                </p>
-              </div>
-            </div><!-- /.form-group -->
-
-            <div class="form-group">
-              <label for="" class="col-sm-4 control-label">
-                {{ t('Upload new image') }}
-              </label>
-              <div class="col-sm-8">
-                {% if isUploadable() %}
-                <form action="/_api/admin/user-group/{{userGroup.id}}/picture/upload" id="pictureUploadForm" method="post" class="form-horizontal" role="form" enctype="multipart/form-data">
-                  <input name="userGroupPicture" type="file" accept="image/*">
-                  <div id="pictureUploadFormProgress">
-                  </div>
-                </form>
-                {% else %} * {{ t('page_me.form_help.profile_image1') }}
-                <br> * {{ t('page_me.form_help.profile_image2') }}
-                <br> {% endif %}
-              </div>
-            </div><!-- /.form-group -->
-
-          </div><!-- /.col-sm- -->
-
-        </fieldset>
-      </div><!-- /.form-box -->
-
       <legend class="m-t-20">ユーザー一覧</legend>
 
       <table class="table table-bordered table-user-list">

+ 0 - 4
src/server/views/admin/user-groups.html

@@ -114,7 +114,6 @@
       <table class="table table-bordered table-user-list">
         <thead>
           <tr>
-            <th width="60px">#</th>
             <th>{{ t('Name') }}</th>
             <th>ユーザ一覧</th>
             <th width="100px">作成日</th>
@@ -125,9 +124,6 @@
           {% for sGroup in userGroups %}
           {% set sGroupDetailPageUrl = '/admin/user-group-detail/' + sGroup._id.toString() %}
           <tr>
-            <td>
-              <img src="{{ sGroup|picture }}" class="picture img-circle" />
-            </td>
             {% if isAclEnabled %}
               <td><a href="{{ sGroupDetailPageUrl }}">{{ sGroup.name | preventXss }}</a></td>
             {% else %}

+ 4 - 13
src/server/views/layout-crowi/widget/page_side_header.html

@@ -24,23 +24,14 @@
         <i class="icon-like"></i> {{ t('Like!') }}
       </dt>
       <dd>
-        <p class="liker-count">
-        <span id="like-count">{{ page.liker.length }}</span>
-        {% if user %}
-        <button
-          data-csrftoken="{{ csrf() }}"
-          data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
-          class="like-button btn btn-xs btn-default btn-outline btn-rounded {% if page.isLiked(user) %}active btn-info{% endif %}"
-          ><i class="icon-like"></i> {{ t('Like!') }}</button>
-        {% endif %}
-        </p>
-        <p id="liker-list" class="liker-list" data-likers="{{ page.liker|default([])|join(',') }}">
-        </p>
+        <p class="liker-user-count">{{ page.liker.length|default(0) }}</p>
+        <div id="liker-list" data-user-ids="{{ page.liker|default([])|join(',') }}"></div>
       </dd>
 
       <dt><i class="fa fa-paw"></i> {{ t('Seen by') }}</dt>
       <dd>
-        <div id="seen-user-list" data-seen-users="{{ page.seenUsers|default([])|join(',') }}"></div>
+          <p class="seen-user-count">{{ page.seenUsers.length|default(0) }}</p>
+        <div id="seen-user-list" data-user-ids="{{ page.seenUsers|default([])|join(',') }}"></div>
       </dd>
     </dl>
   </div>

+ 2 - 1
src/server/views/layout-growi/page.html

@@ -26,7 +26,8 @@
 
     {# relocate #revision-toc #}
     <div class="col-lg-2 col-md-3 revision-toc-container hidden-sm hidden-xs">
-      <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="80">
+      {% include './widget/liker-and-seenusers.html' %}
+      <div id="revision-toc" class="revision-toc mt-3" data-spy="affix" data-offset-top="123">
         <div id="revision-toc-content" class="revision-toc-content"></div>
       </div>
     </div> {# /.col- #}

+ 2 - 1
src/server/views/layout-growi/page_list.html

@@ -26,7 +26,8 @@
 
     {# relocate #revision-toc #}
     <div class="col-lg-2 col-md-3 revision-toc-container hidden-sm hidden-xs">
-      <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="80">
+      {% include './widget/liker-and-seenusers.html' %}
+      <div id="revision-toc" class="revision-toc mt-3" data-spy="affix" data-offset-top="123">
         <div id="revision-toc-content" class="revision-toc-content"></div>
       </div>
     </div> {# /.col- #}

+ 7 - 1
src/server/views/layout-growi/user_page.html

@@ -44,7 +44,13 @@
 
     {# relocate #revision-toc #}
     <div class="col-lg-2 col-md-3 revision-toc-container hidden-sm hidden-xs">
-      <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="75">
+      <div class="liker-and-seenusers d-flex align-items-end justify-content-end">
+        <div class="text-danger">
+          <span id="seen-user-list" class="mr-3" data-user-ids="{{ page.seenUsers|default([])|join(',') }}"></span>
+          <i class="icon-fw fa fa-paw"></i><span class="seen-user-count">{{ page.seenUsers.length|default(0) }}</span>
+        </div>
+      </div>
+      <div id="revision-toc" class="revision-toc mt-3" data-spy="affix" data-offset-top="116">
         <div id="revision-toc-content" class="revision-toc-content"></div>
       </div>
     </div> {# /.col- #}

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

@@ -0,0 +1,10 @@
+<div class="liker-and-seenusers">
+  <div class="text-right text-info">
+    <span id="liker-list" class="mr-3" data-user-ids="{{ page.liker|default([])|join(',') }}"></span>
+    <i class="icon-fw icon-like"></i><span class="liker-user-count">{{ page.liker.length|default(0) }}</span>
+  </div>
+  <div class="text-right text-danger">
+    <span id="seen-user-list" class="mr-3" data-user-ids="{{ page.seenUsers|default([])|join(',') }}"></span>
+    <i class="icon-fw fa fa-paw"></i><span class="seen-user-count">{{ page.seenUsers.length|default(0) }}</span>
+  </div>
+</div>

+ 57 - 25
src/server/views/me/index.html

@@ -157,11 +157,12 @@
             <img src="{{ user|uploadedpicture }}" class="picture picture-lg img-circle" id="settingUserPicture"><br>
             </p>
             <p>
-            {% if user.image %}
-            <form action="/me/picture/delete" method="post" class="form-horizontal" role="form" onsubmit="return window.confirm('{{ t('Delete this image?') }}');">
+            <form id="remove-attachment" action="/_api/attachments.remove" method="post" class="form-horizontal"
+                style="{% if not user.imageAttachment %}display: none{% endif %}">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <input type="hidden" name="attachment_id" value="{{ user.imageAttachment.id }}">
               <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
             </form>
-            {% endif %}
             </p>
           </div>
         </div><!-- /.form-group -->
@@ -172,8 +173,9 @@
           </label>
           <div class="col-sm-8">
             {% if isUploadable() %}
-            <form action="/_api/me/picture/upload" id="pictureUploadForm" method="post" class="form-horizontal" role="form" enctype="multipart/form-data">
-              <input name="userPicture" type="file" accept="image/*">
+            <form action="/_api/attachments.uploadProfileImage" id="pictureUploadForm" method="post" class="form-horizontal" role="form">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <input type="file" name="profileImage" accept="image/*">
               <div id="pictureUploadFormProgress" class="d-flex align-items-center">
               </div>
             </form>
@@ -196,39 +198,69 @@
   </div><!-- /.form-box -->
 
   <script>
-  $(function()
-  {
-    $("#pictureUploadForm input[name=userPicture]").on('change', function(){
-      var $form = $('#pictureUploadForm');
-      var fd = new FormData($form[0]);
+    $("#pictureUploadForm input[name=profileImage]").on('change', function(){
       if ($(this).val() == '') {
         return false;
       }
 
+      var $form = $('#pictureUploadForm');
+      var formData = new FormData();
+      formData.append('file', this.files[0]);
+      formData.append('_csrf', document.getElementsByName("_csrf")[0].value);
+
       $('#pictureUploadFormProgress').html('<div class="speeding-wheel-sm m-r-5"></div> アップロード中...');
       $.ajax($form.attr("action"), {
         type: 'post',
         processData: false,
         contentType: false,
-        data: fd,
-        dataType: 'json',
-        success: function(data){
-          if (data.status) {
-            $('#settingUserPicture').attr('src', data.url + '?time=' + (new Date()));
-            $('#pictureUploadFormMessage')
-              .addClass('alert alert-success')
-              .html('変更しました');
-          } else {
-            $('#pictureUploadFormMessage')
-              .addClass('alert alert-danger')
-              .html('変更中にエラーが発生しました。');
-          }
-          $('#pictureUploadFormProgress').html('');
+        data: formData
+      })
+      .then(function(data) {
+        if (data.ok) {
+          var attachment = data.attachment;
+          $('#settingUserPicture').attr('src', attachment.filePathProxied + '?time=' + (new Date()));
+          $('form#remove-attachment').show();
+          $('form#remove-attachment input[name=attachment_id]').val(attachment.id);
+          $('#pictureUploadFormMessage')
+            .addClass('alert alert-success')
+            .html('変更しました');
+        }
+        else {
+          throw new Error('statis is invalid');
         }
+      })
+      .catch(function(err) {
+        $('#pictureUploadFormMessage')
+          .addClass('alert alert-danger')
+          .html('変更中にエラーが発生しました。');
+      })
+      // finally
+      .then(function() {
+        $('#pictureUploadFormProgress').html('');
       });
       return false;
     });
-  });
+
+    $('form#remove-attachment').on('submit', function(event) {
+      // process with jQuery
+      event.preventDefault();
+
+      $.post($(this).attr('action'), $(this).serializeArray())
+      .then(function(data) {
+        if (data.ok) {
+          $('#settingUserPicture').attr('src', '/images/icons/user.svg');
+          $('form#remove-attachment').hide();
+        }
+        else {
+          throw new Error('statis is invalid');
+        }
+      })
+      .catch(function(err) {
+        $('#pictureUploadFormMessage')
+          .addClass('alert alert-danger')
+          .html('変更中にエラーが発生しました。');
+      })
+    });
   </script>
 
   {% if googleLoginEnabled() %}

+ 6 - 7
src/server/views/widget/header-button-like.html

@@ -1,7 +1,6 @@
-<button
-    data-csrftoken="{{ csrf() }}"
-    data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
-    class="like-button btn btn-default btn-outline btn-circle
-          {% if not size == null %}btn-{{size}}{% endif %}
-          {% if page.isLiked(user) %}active{% endif %}"
-><i class="icon-like"></i></button>
+{# This widget will be rendered by React #}
+{% if not size == null %}
+  <span id="like-button-{{size}}" data-liked="{% if page.isLiked(user) %}true{% else %}false{% endif %}"></span>
+{% else %}
+  <span id="like-button" data-liked="{% if page.isLiked(user) %}true{% else %}false{% endif %}"></span>
+{% endif %}

+ 14 - 0
yarn.lock

@@ -3174,6 +3174,11 @@ es-to-primitive@^1.1.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.1"
 
+es6-object-assign@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c"
+  integrity sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw=
+
 es6-promise@3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.2.1.tgz#ec56233868032909207170c39448e24449dd1fc4"
@@ -6195,6 +6200,15 @@ ms@2.1.1, ms@^2.0.0, ms@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
 
+multer-autoreap@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/multer-autoreap/-/multer-autoreap-1.0.3.tgz#a50aaeb713fa9407ac940807f6c112c6ce9df280"
+  integrity sha512-g0wISfylN2bchQglyAgQTIHoiLUcYQTXKmQh+fKJpheGay9aDqHmcMYRwWRNJ+tK95j9/NZ5QNFkqRytrgw34g==
+  dependencies:
+    debug "^3.1.0"
+    es6-object-assign "^1.1.0"
+    on-finished "^2.3.0"
+
 multer@~1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.0.tgz#c951616c3f97a709b6294acec3a9952a10d33159"