Răsfoiți Sursa

Merge commit '8d42979837a678ffae2136d4c9857b19070b82ca' into feat/enable-to-tag-page

yusuketk 7 ani în urmă
părinte
comite
03d8b14e36
71 a modificat fișierele cu 2312 adăugiri și 2039 ștergeri
  1. 22 2
      CHANGES.md
  2. 15 15
      config/webpack.common.js
  3. 17 13
      config/webpack.dev.js
  4. 23 22
      config/webpack.prod.js
  5. 14 13
      package.json
  6. 34 3
      src/client/js/app.js
  7. 1 1
      src/client/js/components/BookmarkButton.jsx
  8. 50 0
      src/client/js/components/Common/UserPictureList.jsx
  9. 66 0
      src/client/js/components/LikeButton.jsx
  10. 1 1
      src/client/js/components/Page/RevisionLoader.jsx
  11. 8 1
      src/client/js/components/PageAttachment.js
  12. 2 3
      src/client/js/components/PageAttachment/Attachment.js
  13. 1 1
      src/client/js/components/PageAttachment/DeleteAttachmentModal.js
  14. 1 2
      src/client/js/components/PageComment/CommentForm.jsx
  15. 2 3
      src/client/js/components/PageEditor.js
  16. 0 52
      src/client/js/components/SeenUserList.js
  17. 0 44
      src/client/js/components/SeenUserList/UserList.js
  18. 7 1
      src/client/js/components/User/UserPicture.js
  19. 1 1
      src/client/js/hackmd-styles.js
  20. 1 4
      src/client/js/legacy/crowi-presentation.js
  21. 7 71
      src/client/js/legacy/crowi.js
  22. 111 0
      src/client/js/util/reveal/plugins/growi-renderer.js
  23. 379 0
      src/client/js/util/reveal/plugins/markdown.js
  24. 2 0
      src/client/styles/scss/_admin.scss
  25. 35 0
      src/client/styles/scss/_hljs.scss
  26. 200 202
      src/client/styles/scss/_layout_crowi_sidebar.scss
  27. 9 0
      src/client/styles/scss/_layout_growi.scss
  28. 6 1
      src/client/styles/scss/_on-edit.scss
  29. 0 9
      src/client/styles/scss/_override-hljs.scss
  30. 3 3
      src/client/styles/scss/_page.scss
  31. 1 1
      src/client/styles/scss/_user.scss
  32. 15 15
      src/client/styles/scss/_user_growi.scss
  33. 5 30
      src/client/styles/scss/_wiki.scss
  34. 0 3
      src/client/styles/scss/style-app.scss
  35. 137 129
      src/client/styles/scss/style-presentation.scss
  36. 50 137
      src/server/models/attachment.js
  37. 2 40
      src/server/models/comment.js
  38. 33 18
      src/server/models/page.js
  39. 1 18
      src/server/models/user-group.js
  40. 61 73
      src/server/models/user.js
  41. 0 108
      src/server/routes/admin.js
  42. 262 165
      src/server/routes/attachment.js
  43. 7 3
      src/server/routes/comment.js
  44. 1 2
      src/server/routes/hackmd.js
  45. 10 9
      src/server/routes/index.js
  46. 0 31
      src/server/routes/login.js
  47. 0 68
      src/server/routes/me.js
  48. 10 8
      src/server/routes/page.js
  49. 13 16
      src/server/routes/user.js
  50. 66 118
      src/server/service/file-uploader/aws.js
  51. 33 117
      src/server/service/file-uploader/gridfs.js
  52. 2 0
      src/server/service/file-uploader/index.js
  53. 56 42
      src/server/service/file-uploader/local.js
  54. 11 4
      src/server/service/passport.js
  55. 18 19
      src/server/util/middlewares.js
  56. 3 7
      src/server/views/admin/customize.html
  57. 0 49
      src/server/views/admin/user-group-detail.html
  58. 0 4
      src/server/views/admin/user-groups.html
  59. 4 13
      src/server/views/layout-crowi/widget/page_side_header.html
  60. 2 1
      src/server/views/layout-growi/page.html
  61. 2 1
      src/server/views/layout-growi/page_list.html
  62. 7 1
      src/server/views/layout-growi/user_page.html
  63. 10 0
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  64. 9 4
      src/server/views/layout/layout.html
  65. 57 25
      src/server/views/me/index.html
  66. 1 1
      src/server/views/modal/unportalize.html
  67. 3 4
      src/server/views/page_presentation.html
  68. 6 7
      src/server/views/widget/header-button-like.html
  69. 3 6
      src/server/views/widget/page_alerts.html
  70. 5 2
      src/server/views/widget/page_tabs.html
  71. 388 272
      yarn.lock

+ 22 - 2
CHANGES.md

@@ -1,9 +1,29 @@
 CHANGES
 CHANGES
 ========
 ========
 
 
-## 3.3.10-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
+
+* Feature: PlantUML and Blockdiag on presentation
+* Improvement: Render slides of presentation with GrowiRenderer
+* Fix: Unportalizing doesn't work
+* Support: Use mini-css-extract-plugin instead of extract extract-text-webpack-plugin
+* Support: Use terser-webpack-plugin instead of uglifyjs-webpack-plugin
+* Support: Upgrade libs
+    * csv-to-markdown-table
+    * file-loader
+    * googleapis
+    * i18next-browser-languagedetector
+    * mocha
+    * react-waypoint
+    * webpack
+    * webpack-assets-manifest
+    * webpack-cli
+    * webpack-merge
 
 
 ## 3.3.9
 ## 3.3.9
 
 

+ 15 - 15
config/webpack.common.js

@@ -29,7 +29,7 @@ module.exports = (options) => {
       'js/hackmd-agent':              './src/client/js/hackmd-agent',
       'js/hackmd-agent':              './src/client/js/hackmd-agent',
       'js/hackmd-styles':             './src/client/js/hackmd-styles',
       'js/hackmd-styles':             './src/client/js/hackmd-styles',
       // styles
       // styles
-      'styles/style':                 './src/client/styles/scss/style.scss',
+      'styles/style-app':             './src/client/styles/scss/style-app.scss',
       'styles/style-presentation':    './src/client/styles/scss/style-presentation.scss',
       'styles/style-presentation':    './src/client/styles/scss/style-presentation.scss',
       // themes
       // themes
       'styles/theme-default':         './src/client/styles/scss/theme/default.scss',
       'styles/theme-default':         './src/client/styles/scss/theme/default.scss',
@@ -100,16 +100,6 @@ module.exports = (options) => {
           test: /switchery\.js$/,
           test: /switchery\.js$/,
           loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window'
           loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window'
         },
         },
-        {
-          test: /\.css$/,
-          use: ['style-loader', 'css-loader'],
-          exclude: [helpers.root('src/client/styles')]
-        },
-        {
-          test: /\.scss$/,
-          use: ['style-loader', 'css-loader', 'sass-loader'],
-          exclude: [helpers.root('src/client/styles')]
-        },
         /*
         /*
          * File loader for supporting images, for example, in CSS files.
          * File loader for supporting images, for example, in CSS files.
          */
          */
@@ -153,8 +143,19 @@ module.exports = (options) => {
       namedModules: true,
       namedModules: true,
       splitChunks: {
       splitChunks: {
         cacheGroups: {
         cacheGroups: {
+          style_commons: {
+            test: /\.(sc|sa|c)ss$/,
+            chunks: (chunk) => {
+              // ignore patterns
+              return chunk.name != null && !chunk.name.match(/style-|theme-|legacy-admin|legacy-presentation/);
+            },
+            name: 'styles/style-commons',
+            minSize: 1,
+            priority: 30,
+            enforce: true
+          },
           commons: {
           commons: {
-            test: /src/,
+            test: /src[\\/].*\.jsx?$/,
             chunks: 'initial',
             chunks: 'initial',
             name: 'js/commons',
             name: 'js/commons',
             minChunks: 2,
             minChunks: 2,
@@ -162,17 +163,16 @@ module.exports = (options) => {
             priority: 20
             priority: 20
           },
           },
           vendors: {
           vendors: {
-            test: /node_modules/,
+            test: /node_modules[\\/].*\.jsx?$/,
             chunks: (chunk) => {
             chunks: (chunk) => {
               // ignore patterns
               // ignore patterns
               return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);
               return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);
             },
             },
             name: 'js/vendors',
             name: 'js/vendors',
-            // minChunks: 2,
             minSize: 1,
             minSize: 1,
             priority: 10,
             priority: 10,
             enforce: true
             enforce: true
-          }
+          },
         }
         }
       },
       },
       minimizer: options.optimization.minimizer || [],
       minimizer: options.optimization.minimizer || [],

+ 17 - 13
config/webpack.dev.js

@@ -2,14 +2,13 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
  */
 
 
-const path = require('path');
 const webpack = require('webpack');
 const webpack = require('webpack');
 const helpers = require('../src/lib/util/helpers');
 const helpers = require('../src/lib/util/helpers');
 
 
 /*
 /*
  * Webpack Plugins
  * Webpack Plugins
  */
  */
-const ExtractTextPlugin = require('extract-text-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
 
 /**
 /**
@@ -29,29 +28,34 @@ module.exports = require('./webpack.common')({
   module: {
   module: {
     rules: [
     rules: [
       {
       {
-        test: /\.scss$/,
+        test: /\.(css|scss)$/,
         use: [
         use: [
           'style-loader',
           'style-loader',
           { loader: 'css-loader', options: { sourceMap: true } },
           { loader: 'css-loader', options: { sourceMap: true } },
           { loader: 'sass-loader', options: { sourceMap: true } },
           { loader: 'sass-loader', options: { sourceMap: true } },
         ],
         ],
-        include: [helpers.root('src/client/styles/scss')]
+        exclude: [
+          helpers.root('src/client/styles/hackmd'),
+          helpers.root('src/client/styles/scss/style-presentation.scss'),
+        ]
       },
       },
       { // Dump CSS for HackMD
       { // Dump CSS for HackMD
-        test: /\.scss$/,
-        use: ExtractTextPlugin.extract({
-          use: [
-            'css-loader',
-            'sass-loader'
-          ]
-        }),
-        include: [helpers.root('src/client/styles/hackmd')]
+        test: /\.(css|scss)$/,
+        use: [
+          MiniCssExtractPlugin.loader,
+          'css-loader',
+          'sass-loader'
+        ],
+        include: [
+          helpers.root('src/client/styles/hackmd'),
+          helpers.root('src/client/styles/scss/style-presentation.scss'),
+        ]
       },
       },
     ],
     ],
   },
   },
   plugins: [
   plugins: [
 
 
-    new ExtractTextPlugin({
+    new MiniCssExtractPlugin({
       filename: '[name].bundle.css',
       filename: '[name].bundle.css',
     }),
     }),
 
 

+ 23 - 22
config/webpack.prod.js

@@ -6,8 +6,8 @@ const helpers = require('../src/lib/util/helpers');
 /**
 /**
  * Webpack Plugins
  * Webpack Plugins
  */
  */
-const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
-const ExtractTextPlugin = require('extract-text-webpack-plugin');
+const TerserPlugin = require('terser-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
 const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
 
@@ -26,26 +26,30 @@ module.exports = require('./webpack.common')({
   module: {
   module: {
     rules: [
     rules: [
       {
       {
-        test: /\.scss$/,
-        use: ExtractTextPlugin.extract({
-          use: [
-            'css-loader',
-            { loader: 'postcss-loader', options: {
-              sourceMap: false,
-              plugins: (loader) => [
-                require('autoprefixer')()
-              ]
-            } },
-            'sass-loader'
-          ]
-        }),
-        include: [helpers.root('src/client/styles/scss'), helpers.root('src/client/styles/hackmd')]
-      }
+        test: /\.(css|scss)$/,
+        use: [
+          MiniCssExtractPlugin.loader,
+          'css-loader',
+          { loader: 'postcss-loader', options: {
+            sourceMap: false,
+            plugins: (loader) => [
+              require('autoprefixer')()
+            ]
+          } },
+          'sass-loader'
+        ],
+        exclude: [helpers.root('src/client/js/legacy')]
+      },
+      {
+        test: /\.(css|scss)$/,
+        use: ['style-loader', 'css-loader', 'sass-loader'],
+        include: [helpers.root('src/client/js/legacy')]
+      },
     ]
     ]
   },
   },
   plugins: [
   plugins: [
 
 
-    new ExtractTextPlugin({
+    new MiniCssExtractPlugin({
       filename: '[name].[hash].css',
       filename: '[name].[hash].css',
     }),
     }),
 
 
@@ -58,10 +62,7 @@ module.exports = require('./webpack.common')({
   ],
   ],
   optimization: {
   optimization: {
     minimizer: [
     minimizer: [
-      new UglifyJsPlugin({
-        cache: true,
-        parallel: true,
-      }),
+      new TerserPlugin({}),
       new OptimizeCSSAssetsPlugin({})
       new OptimizeCSSAssetsPlugin({})
     ],
     ],
   },
   },

+ 14 - 13
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.3.10-RC",
+  "version": "3.4.0-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -83,7 +83,7 @@
     "express-sanitizer": "^1.0.4",
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^36.0.0",
+    "googleapis": "^37.0.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
     "helmet": "^3.13.0",
@@ -101,6 +101,7 @@
     "mongoose-paginate": "^5.0.3",
     "mongoose-paginate": "^5.0.3",
     "mongoose-unique-validator": "^2.0.2",
     "mongoose-unique-validator": "^2.0.2",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
+    "multer-autoreap": "^1.0.3",
     "nodemailer": "^5.1.1",
     "nodemailer": "^5.1.1",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "npm-run-all": "^4.1.2",
@@ -143,16 +144,15 @@
     "commander": "^2.11.0",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
     "connect-browser-sync": "^2.1.0",
     "css-loader": "^1.0.0",
     "css-loader": "^1.0.0",
-    "csv-to-markdown-table": "^0.4.0",
+    "csv-to-markdown-table": "^0.5.0",
     "date-fns": "^1.29.0",
     "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
     "eazy-logger": "^3.0.2",
     "eslint": "^5.0.0",
     "eslint": "^5.0.0",
     "eslint-plugin-react": "^7.7.0",
     "eslint-plugin-react": "^7.7.0",
-    "extract-text-webpack-plugin": "^4.0.0-beta.0",
-    "file-loader": "^2.0.0",
+    "file-loader": "^3.0.1",
     "handsontable": "^6.0.1",
     "handsontable": "^6.0.1",
-    "i18next-browser-languagedetector": "^2.2.0",
+    "i18next-browser-languagedetector": "^3.0.1",
     "imports-loader": "^0.8.0",
     "imports-loader": "^0.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery-ui": "^1.12.1",
@@ -170,7 +170,8 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
     "metismenu": "^3.0.3",
-    "mocha": "^5.2.0",
+    "mini-css-extract-plugin": "^0.5.0",
+    "mocha": "^6.0.1",
     "morgan": "^1.9.0",
     "morgan": "^1.9.0",
     "node-dev": "^3.1.3",
     "node-dev": "^3.1.3",
     "node-sass": "^4.11.0",
     "node-sass": "^4.11.0",
@@ -191,7 +192,7 @@
     "react-dropzone": "=7.0.1",
     "react-dropzone": "=7.0.1",
     "react-frame-component": "^4.0.0",
     "react-frame-component": "^4.0.0",
     "react-i18next": "=7.13.0",
     "react-i18next": "=7.13.0",
-    "react-waypoint": "^8.1.0",
+    "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "sass-loader": "^7.1.0",
@@ -201,14 +202,14 @@
     "socket.io-client": "^2.0.3",
     "socket.io-client": "^2.0.3",
     "stream-to-promise": "^2.2.0",
     "stream-to-promise": "^2.2.0",
     "style-loader": "^0.23.0",
     "style-loader": "^0.23.0",
+    "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
-    "uglifyjs-webpack-plugin": "^2.0.1",
-    "webpack": "^4.12.0",
-    "webpack-assets-manifest": "^3.0.1",
+    "webpack": "^4.29.3",
+    "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.0.2",
     "webpack-bundle-analyzer": "^3.0.2",
-    "webpack-cli": "^3.0.8",
-    "webpack-merge": "~4.1.0"
+    "webpack-cli": "^3.2.3",
+    "webpack-merge": "^4.2.1"
   },
   },
   "_moduleAliases": {
   "_moduleAliases": {
     "@root": ".",
     "@root": ".",

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

@@ -25,13 +25,14 @@ import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
 import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment   from './components/PageAttachment';
 import PageAttachment   from './components/PageAttachment';
 import PageStatusAlert  from './components/PageStatusAlert';
 import PageStatusAlert  from './components/PageStatusAlert';
-import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionPath     from './components/Page/RevisionPath';
 import PageTagForm      from './components/PageTagForm';
 import PageTagForm      from './components/PageTagForm';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
 import BookmarkButton   from './components/BookmarkButton';
+import LikeButton       from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import UserPictureList  from './components/Common/UserPictureList';
 
 
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -288,7 +289,6 @@ const componentMappings = {
   'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
   'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
 
 
   //'revision-history': <PageHistory pageId={pageId} />,
   //'revision-history': <PageHistory pageId={pageId} />,
-  'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
 
@@ -300,7 +300,7 @@ const componentMappings = {
 // additional definitions if data exists
 // additional definitions if data exists
 if (pageId) {
 if (pageId) {
   componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
   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) {
 if (pagePath) {
   componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} onSaveWithShortcut={saveWithShortcut} />;
   componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} onSaveWithShortcut={saveWithShortcut} />;
@@ -321,6 +321,37 @@ if (componentInstances['page'] != null) {
   crowi.setPage(componentInstances['page']);
   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
 // render SavePageControls
 let savePageControls = null;
 let savePageControls = null;
 const savePageControlsElem = document.getElementById('save-page-controls');
 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 (
     return (
       <button href="#" title="Bookmark" onClick={this.handleClick}
       <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>
         <i className="icon-star"></i>
       </button>
       </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,
+};

+ 1 - 1
src/client/js/components/Page/RevisionLoader.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import Waypoint  from 'react-waypoint';
+import { Waypoint } from 'react-waypoint';
 
 
 import RevisionRenderer from './RevisionRenderer';
 import RevisionRenderer from './RevisionRenderer';
 
 

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

@@ -1,4 +1,5 @@
 import React from 'react';
 import React from 'react';
+import PropTypes from 'prop-types';
 
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
@@ -43,7 +44,7 @@ export default class PageAttachment extends React.Component {
   }
   }
 
 
   checkIfFileInUse(attachment) {
   checkIfFileInUse(attachment) {
-    if (this.props.pageContent.match(attachment.url)) {
+    if (this.props.markdown.match(attachment.filePathProxied)) {
       return true;
       return true;
     }
     }
     return false;
     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)
     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>
           <i className="icon-cloud-download"></i>
         </a>)
         </a>)
       : '';
       : '';
@@ -50,9 +50,8 @@ export default class Attachment extends React.Component {
     return (
     return (
       <li>
       <li>
         <User user={attachment.creator} />
         <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}
         {fileType}
 
 

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

@@ -25,7 +25,7 @@ export default class DeleteAttachmentModal extends React.Component {
 
 
   renderByFileFormat(attachment) {
   renderByFileFormat(attachment) {
     const content = (attachment.fileFormat.match(/image\/.+/i))
     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
     // post
     this.props.crowi.apiPost(endpoint, formData)
     this.props.crowi.apiPost(endpoint, formData)
       .then((res) => {
       .then((res) => {
-        const url = res.url;
         const attachment = res.attachment;
         const attachment = res.attachment;
         const fileName = attachment.originalName;
         const fileName = attachment.originalName;
 
 
-        let insertText = `[${fileName}](${url})`;
+        let insertText = `[${fileName}](${attachment.filePathProxied})`;
         // when image
         // when image
         if (attachment.fileFormat.startsWith('image/')) {
         if (attachment.fileFormat.startsWith('image/')) {
           // modify to "![fileName](url)" syntax
           // 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);
       formData.append('page_id', this.state.pageId || 0);
 
 
       // post
       // 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 attachment = res.attachment;
       const fileName = attachment.originalName;
       const fileName = attachment.originalName;
 
 
-      let insertText = `[${fileName}](${url})`;
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
       // when image
       // when image
       if (attachment.fileFormat.startsWith('image/')) {
       if (attachment.fileFormat.startsWith('image/')) {
         // modify to "![fileName](url)" syntax
         // 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);
       return this.generateGravatarSrc(user);
     }
     }
     // uploaded image
     // uploaded image
+    else if (user.image != null) {
+      return user.image;
+    }
+    else if (user.imageAttachment != null) {
+      return user.imageAttachment.filePathProxied;
+    }
     else {
     else {
-      return user.image || '/images/icons/user.svg';
+      return '/images/icons/user.svg';
     }
     }
   }
   }
 
 

+ 1 - 1
src/client/js/hackmd-styles.js

@@ -20,7 +20,7 @@ const styles = '{{styles}}';         // will be replaced by swig
 function insertStyle() {
 function insertStyle() {
   const element = document.createElement('style');
   const element = document.createElement('style');
   element.type = 'text/css';
   element.type = 'text/css';
-  element.appendChild(document.createTextNode(styles));
+  element.appendChild(document.createTextNode(unescape(styles)));
   document.getElementsByTagName('head')[0].appendChild(element);
   document.getElementsByTagName('head')[0].appendChild(element);
 }
 }
 
 

+ 1 - 4
src/client/js/legacy/crowi-presentation.js

@@ -1,7 +1,5 @@
 const Reveal = require('reveal.js');
 const Reveal = require('reveal.js');
 
 
-require('reveal.js/css/reveal.css');
-require('reveal.js/css/theme/black.css');
 require('reveal.js/lib/js/head.min.js');
 require('reveal.js/lib/js/head.min.js');
 require('reveal.js/lib/js/html5shiv.js');
 require('reveal.js/lib/js/html5shiv.js');
 
 
@@ -33,10 +31,9 @@ Reveal.initialize({
 
 
 require.ensure([], () => {
 require.ensure([], () => {
   require('reveal.js/lib/js/classList.js');
   require('reveal.js/lib/js/classList.js');
-  require('reveal.js/plugin/markdown/marked.js');
-  require('reveal.js/plugin/markdown/markdown.js');
   require('reveal.js/plugin/zoom-js/zoom.js');
   require('reveal.js/plugin/zoom-js/zoom.js');
   require('reveal.js/plugin/notes/notes.js');
   require('reveal.js/plugin/notes/notes.js');
+  require('../util/reveal/plugins/growi-renderer.js');
 
 
   // fix https://github.com/weseek/crowi-plus/issues/96
   // fix https://github.com/weseek/crowi-plus/issues/96
   Reveal.slide(0, 0);
   Reveal.slide(0, 0);

+ 7 - 71
src/client/js/legacy/crowi.js

@@ -2,9 +2,6 @@
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 */
 
 
-/* global crowi: true */
-/* global crowiRenderer: true */
-
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 
 
@@ -58,6 +55,7 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
     return;
   }
   }
 
 
+  const crowi = window.crowi;
   const line = pageEditorDom.getAttribute('data-caret-line') || 0;
   const line = pageEditorDom.getAttribute('data-caret-line') || 0;
   crowi.setCaretLine(+line);
   crowi.setCaretLine(+line);
   // reset data-caret-line attribute
   // reset data-caret-line attribute
@@ -272,6 +270,7 @@ Crowi.getCurrentEditorMode = function() {
 };
 };
 
 
 $(function() {
 $(function() {
+  const crowi = window.crowi;
   const config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
   const config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
 
 
   const pageId = $('#content-main').data('page-id');
   const pageId = $('#content-main').data('page-id');
@@ -374,7 +373,7 @@ $(function() {
         $('#renamePage .msg, #unportalize .msg').hide();
         $('#renamePage .msg, #unportalize .msg').hide();
         $(`#renamePage .msg-${res.code}, #unportalize .msg-${res.code}`).show();
         $(`#renamePage .msg-${res.code}, #unportalize .msg-${res.code}`).show();
         $('#renamePage #linkToNewPage, #unportalize #linkToNewPage').html(`
         $('#renamePage #linkToNewPage, #unportalize #linkToNewPage').html(`
-          <a href="${nameValueMap.q}">${nameValueMap.q} <i class="icon-login"></i></a>
+          <a href="${nameValueMap.new_path}">${nameValueMap.new_path} <i class="icon-login"></i></a>
         `);
         `);
       }
       }
       else {
       else {
@@ -541,6 +540,8 @@ $(function() {
     const isShown = $('#view-timeline').data('shown');
     const isShown = $('#view-timeline').data('shown');
 
 
     if (growiRendererForTimeline == null) {
     if (growiRendererForTimeline == null) {
+      const crowi = window.crowi;
+      const crowiRenderer = window.crowiRenderer;
       growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, {mode: 'timeline'});
       growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, {mode: 'timeline'});
     }
     }
 
 
@@ -580,72 +581,6 @@ $(function() {
       top.location.href = `${path}#edit`;
       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) {
     if (!isSeen) {
       $.post('/_api/pages.seen', {page_id: pageId}, function(res) {
       $.post('/_api/pages.seen', {page_id: pageId}, function(res) {
         // ignore unless response has error
         // ignore unless response has error
@@ -763,7 +698,8 @@ window.addEventListener('load', function(e) {
     }
     }
   }
   }
 
 
-  if (crowi && crowi.users || crowi.users.length == 0) {
+  const crowi = window.crowi;
+  if (crowi && crowi.users && crowi.users.length != 0) {
     const totalUsers = crowi.users.length;
     const totalUsers = crowi.users.length;
     const $listLiker = $('.page-list-liker');
     const $listLiker = $('.page-list-liker');
     $listLiker.each(function(i, liker) {
     $listLiker.each(function(i, liker) {

+ 111 - 0
src/client/js/util/reveal/plugins/growi-renderer.js

@@ -0,0 +1,111 @@
+import GrowiRenderer from '../../GrowiRenderer';
+
+/**
+ * reveal.js growi-renderer plugin.
+ */
+(function(root, factory) {
+  // parent window DOM (crowi.js) of presentation window.
+  let parentWindow = window.parent;
+
+  // create GrowiRenderer instance and setup.
+  let growiRenderer = new GrowiRenderer(parentWindow.crowi, parentWindow.crowiRenderer, {mode: 'editor'});
+
+  let growiRendererPlugin = factory(growiRenderer);
+  growiRendererPlugin.initialize();
+}(this, function(growiRenderer) {
+  const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$',
+    DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$',
+    DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
+  let marked;
+
+  /**
+   * Add data separator before lines
+   * starting with '#' to markdown.
+   */
+  function divideSlides() {
+    let sections = document.querySelectorAll('[data-markdown]');
+    for (let i = 0, len = sections.length; i < len; i++) {
+      let section = sections[i];
+      let markdown = marked.getMarkdownFromSlide(section);
+      let context = {markdown};
+      const interceptorManager = growiRenderer.crowi.interceptorManager;
+      let dataSeparator = section.getAttribute( 'data-separator' ) || DEFAULT_SLIDE_SEPARATOR;
+      // replace string '\n' to LF code.
+      dataSeparator = dataSeparator.replace(/\\n/g, '\n');
+      const replaceValue = dataSeparator + '#';
+      // detach code block.
+      interceptorManager.process('prePreProcess', context);
+      // if there is only '\n' in the first line, replace it.
+      context.markdown = context.markdown.replace(/^\n/, '');
+      // add data separator to markdown.
+      context.markdown = context.markdown.replace(/[\n]+#/g, replaceValue);
+      // restore code block.
+      interceptorManager.process('postPreProcess', context);
+      section.innerHTML = marked.createMarkdownSlide(context.markdown);
+    }
+  }
+
+  /**
+   * Converts data-markdown slides to HTML slides by GrowiRenderer.
+   */
+  function convertSlides() {
+    let sections = document.querySelectorAll('[data-markdown]');
+    let markdown;
+    const interceptorManager = growiRenderer.crowi.interceptorManager;
+
+    for (let i = 0, len = sections.length; i < len; i++) {
+      let section = sections[i];
+
+      // Only parse the same slide once
+      if (!section.getAttribute('data-markdown-parsed')) {
+        section.setAttribute('data-markdown-parsed', 'true');
+        let notes = section.querySelector( 'aside.notes' );
+        markdown = marked.getMarkdownFromSlide(section);
+        let context = { markdown };
+
+        interceptorManager.process('preRender', context)
+          .then(() => interceptorManager.process('prePreProcess', context))
+          .then(() => {
+            context.markdown = growiRenderer.preProcess(context.markdown);
+          })
+          .then(() => interceptorManager.process('postPreProcess', context))
+          .then(() => {
+            context['parsedHTML'] = growiRenderer.process(context.markdown);
+          })
+          .then(() => interceptorManager.process('prePostProcess', context))
+          .then(() => {
+            context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+          })
+          .then(() => interceptorManager.process('postPostProcess', context))
+          .then(() => interceptorManager.process('preRenderHtml', context))
+          .then(() => interceptorManager.process('postRenderHtml', context))
+          .then(() => {
+            section.innerHTML = context.parsedHTML;
+          });
+        marked.addAttributes(   section, section, null, section.getAttribute( 'data-element-attributes' ) ||
+          section.parentNode.getAttribute( 'data-element-attributes' ) ||
+          DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR,
+        section.getAttribute( 'data-attributes' ) ||
+          section.parentNode.getAttribute( 'data-attributes' ) ||
+          DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR);
+
+        // If there were notes, we need to re-add them after
+        // having overwritten the section's HTML
+        if ( notes ) {
+          section.appendChild( notes );
+        }
+      }
+    }
+  }
+
+  // API
+  return {
+    initialize: async function() {
+      growiRenderer.setup();
+      marked = require('./markdown').default(growiRenderer.process);
+      divideSlides();
+      marked.processSlides();
+      convertSlides();
+    }
+  };
+}));

+ 379 - 0
src/client/js/util/reveal/plugins/markdown.js

@@ -0,0 +1,379 @@
+/**
+ * The reveal.js markdown plugin. Handles parsing of
+ * markdown inside of presentations as well as loading
+ * of external markdown documents.
+ * Referred from The reveal.js markdown plugin.
+ * https://github.com/hakimel/reveal.js/blob/master/plugin/markdown/markdown.js
+ */
+export default function( marked ) {
+
+  const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$',
+    DEFAULT_NOTES_SEPARATOR = 'notes?:',
+    DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$',
+    DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
+
+  const SCRIPT_END_PLACEHOLDER = '__SCRIPT_END__';
+
+
+  /**
+   * Retrieves the markdown contents of a slide section
+   * element. Normalizes leading tabs/whitespace.
+   */
+  function getMarkdownFromSlide( section ) {
+
+    // look for a <script> or <textarea data-template> wrapper
+    let template = section.querySelector( '[data-template]' ) || section.querySelector( 'script' );
+
+    // strip leading whitespace so it isn't evaluated as code
+    let text = ( template || section ).textContent;
+
+    // restore script end tags
+    text = text.replace( new RegExp( SCRIPT_END_PLACEHOLDER, 'g' ), '</script>' );
+
+    let leadingWs = text.match( /^\n?(\s*)/ )[1].length,
+      leadingTabs = text.match( /^\n?(\t*)/ )[1].length;
+
+    if ( leadingTabs > 0 ) {
+      text = text.replace( new RegExp('\\n?\\t{' + leadingTabs + '}', 'g'), '\n' );
+    }
+    else if ( leadingWs > 1 ) {
+      text = text.replace( new RegExp('\\n? {' + leadingWs + '}', 'g'), '\n' );
+    }
+
+    return text;
+
+  }
+
+  /**
+   * Given a markdown slide section element, this will
+   * return all arguments that aren't related to markdown
+   * parsing. Used to forward any other user-defined arguments
+   * to the output markdown slide.
+   */
+  function getForwardedAttributes( section ) {
+
+    let attributes = section.attributes;
+    let result = [];
+
+    for ( let i = 0, len = attributes.length; i < len; i++ ) {
+      let name = attributes[i].name,
+        value = attributes[i].value;
+
+      // disregard attributes that are used for markdown loading/parsing
+      if ( /data\-(markdown|separator|vertical|notes)/gi.test( name ) ) continue;
+
+      if ( value ) {
+        result.push( name + '="' + value + '"' );
+      }
+      else {
+        result.push( name );
+      }
+    }
+
+    return result.join( ' ' );
+
+  }
+
+  /**
+   * Inspects the given options and fills out default
+   * values for what's not defined.
+   */
+  function getSlidifyOptions( options ) {
+
+    options = options || {};
+    options.separator = options.separator || DEFAULT_SLIDE_SEPARATOR;
+    options.notesSeparator = options.notesSeparator || DEFAULT_NOTES_SEPARATOR;
+    options.attributes = options.attributes || '';
+
+    return options;
+
+  }
+
+  /**
+   * Helper function for constructing a markdown slide.
+   */
+  function createMarkdownSlide( content, options ) {
+
+    options = getSlidifyOptions( options );
+
+    let notesMatch = content.split( new RegExp( options.notesSeparator, 'mgi' ) );
+
+    if ( notesMatch.length === 2 ) {
+      content = notesMatch[0] + '<aside class="notes">' + marked(notesMatch[1].trim()) + '</aside>';
+    }
+
+    // prevent script end tags in the content from interfering
+    // with parsing
+    content = content.replace( /<\/script>/g, SCRIPT_END_PLACEHOLDER );
+
+    return '<script type="text/template">' + content + '</script>';
+
+  }
+
+  /**
+   * Parses a data string into multiple slides based
+   * on the passed in separator arguments.
+   */
+  function slidify( markdown, options ) {
+
+    options = getSlidifyOptions( options );
+
+    let separatorRegex = new RegExp( options.separator + ( options.verticalSeparator ? '|' + options.verticalSeparator : '' ), 'mg' ),
+      horizontalSeparatorRegex = new RegExp( options.separator );
+
+    let matches,
+      lastIndex = 0,
+      isHorizontal,
+      wasHorizontal = true,
+      content,
+      sectionStack = [];
+
+    // iterate until all blocks between separators are stacked up
+    while ( (matches = separatorRegex.exec( markdown )) != null ) {
+      // notes = null;
+
+      // determine direction (horizontal by default)
+      isHorizontal = horizontalSeparatorRegex.test( matches[0] );
+
+      if ( !isHorizontal && wasHorizontal ) {
+        // create vertical stack
+        sectionStack.push( [] );
+      }
+
+      // pluck slide content from markdown input
+      content = markdown.substring( lastIndex, matches.index );
+
+      if ( isHorizontal && wasHorizontal ) {
+        // add to horizontal stack
+        sectionStack.push( content );
+      }
+      else {
+        // add to vertical stack
+        sectionStack[sectionStack.length-1].push( content );
+      }
+
+      lastIndex = separatorRegex.lastIndex;
+      wasHorizontal = isHorizontal;
+    }
+
+    // add the remaining slide
+    ( wasHorizontal ? sectionStack : sectionStack[sectionStack.length-1] ).push( markdown.substring( lastIndex ) );
+
+    let markdownSections = '';
+
+    // flatten the hierarchical stack, and insert <section data-markdown> tags
+    for ( let i = 0, len = sectionStack.length; i < len; i++ ) {
+      // vertical
+      if ( sectionStack[i] instanceof Array ) {
+        markdownSections += '<section '+ options.attributes +'>';
+
+        sectionStack[i].forEach( function( child ) {
+          markdownSections += '<section data-markdown>' + createMarkdownSlide( child, options ) + '</section>';
+        } );
+
+        markdownSections += '</section>';
+      }
+      else {
+        markdownSections += '<section '+ options.attributes +' data-markdown>' + createMarkdownSlide( sectionStack[i], options ) + '</section>';
+      }
+    }
+
+    return markdownSections;
+
+  }
+
+  /**
+   * Parses any current data-markdown slides, splits
+   * multi-slide markdown into separate sections and
+   * handles loading of external markdown.
+   */
+  function processSlides() {
+
+    let sections = document.querySelectorAll( '[data-markdown]'),
+      section;
+
+    for ( let i = 0, len = sections.length; i < len; i++ ) {
+
+      section = sections[i];
+
+      if ( section.getAttribute( 'data-markdown' ).length ) {
+
+        let xhr = new XMLHttpRequest(),
+          url = section.getAttribute( 'data-markdown' );
+
+        let datacharset = section.getAttribute( 'data-charset' );
+
+        // see https://developer.mozilla.org/en-US/docs/Web/API/element.getAttribute#Notes
+        if ( datacharset != null && datacharset != '' ) {
+          xhr.overrideMimeType( 'text/html; charset=' + datacharset );
+        }
+
+        xhr.onreadystatechange = function() {
+          if ( xhr.readyState === 4 ) {
+            // file protocol yields status code 0 (useful for local debug, mobile applications etc.)
+            if ( ( xhr.status >= 200 && xhr.status < 300 ) || xhr.status === 0 ) {
+
+              section.outerHTML = slidify( xhr.responseText, {
+                separator: section.getAttribute( 'data-separator' ),
+                verticalSeparator: section.getAttribute( 'data-separator-vertical' ),
+                notesSeparator: section.getAttribute( 'data-separator-notes' ),
+                attributes: getForwardedAttributes( section )
+              });
+
+            }
+            else {
+
+              section.outerHTML = '<section data-state="alert">' +
+                'ERROR: The attempt to fetch ' + url + ' failed with HTTP status ' + xhr.status + '.' +
+                'Check your browser\'s JavaScript console for more details.' +
+                '<p>Remember that you need to serve the presentation HTML from a HTTP server.</p>' +
+                '</section>';
+
+            }
+          }
+        };
+
+        xhr.open( 'GET', url, false );
+
+        try {
+          xhr.send();
+        }
+        catch ( e ) {
+          alert( 'Failed to get the Markdown file ' + url + '. Make sure that the presentation and the file are served by a HTTP server and the file can be found there. ' + e );
+        }
+
+      }
+      else if ( section.getAttribute( 'data-separator' ) || section.getAttribute( 'data-separator-vertical' ) || section.getAttribute( 'data-separator-notes' ) ) {
+
+        section.outerHTML = slidify( getMarkdownFromSlide( section ), {
+          separator: section.getAttribute( 'data-separator' ),
+          verticalSeparator: section.getAttribute( 'data-separator-vertical' ),
+          notesSeparator: section.getAttribute( 'data-separator-notes' ),
+          attributes: getForwardedAttributes( section )
+        });
+
+      }
+      else {
+        section.innerHTML = createMarkdownSlide( getMarkdownFromSlide( section ) );
+      }
+    }
+
+  }
+
+  /**
+   * Check if a node value has the attributes pattern.
+   * If yes, extract it and add that value as one or several attributes
+   * the the terget element.
+   *
+   * You need Cache Killer on Chrome to see the effect on any FOM transformation
+   * directly on refresh (F5)
+   * http://stackoverflow.com/questions/5690269/disabling-chrome-cache-for-website-development/7000899#answer-11786277
+   */
+  function addAttributeInElement( node, elementTarget, separator ) {
+
+    let mardownClassesInElementsRegex = new RegExp( separator, 'mg' );
+    let mardownClassRegex = new RegExp( "([^\"= ]+?)=\"([^\"=]+?)\"", 'mg' );
+    let nodeValue = node.nodeValue;
+    let matches = mardownClassesInElementsRegex.exec( nodeValue );
+    if ( matches != null ) {
+
+      let classes = matches[1];
+      nodeValue = nodeValue.substring( 0, matches.index ) + nodeValue.substring( mardownClassesInElementsRegex.lastIndex );
+      node.nodeValue = nodeValue;
+      let matchesClass;
+      while ( (matchesClass = mardownClassRegex.exec( classes )) != null ) {
+        elementTarget.setAttribute( matchesClass[1], matchesClass[2] );
+      }
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Add attributes to the parent element of a text node,
+   * or the element of an attribute node.
+   */
+  function addAttributes( section, element, previousElement, separatorElementAttributes, separatorSectionAttributes ) {
+
+    if ( element != null && element.childNodes != undefined && element.childNodes.length > 0 ) {
+      let previousParentElement = element;
+      for ( let i = 0; i < element.childNodes.length; i++ ) {
+        let childElement = element.childNodes[i];
+        if ( i > 0 ) {
+          let j = i - 1;
+          while ( j >= 0 ) {
+            let aPreviousChildElement = element.childNodes[j];
+            if ( typeof aPreviousChildElement.setAttribute == 'function' && aPreviousChildElement.tagName != 'BR' ) {
+              previousParentElement = aPreviousChildElement;
+              break;
+            }
+            j = j - 1;
+          }
+        }
+        let parentSection = section;
+        if ( childElement.nodeName ==  'section' ) {
+          parentSection = childElement ;
+          previousParentElement = childElement ;
+        }
+        if ( typeof childElement.setAttribute == 'function' || childElement.nodeType == Node.COMMENT_NODE ) {
+          addAttributes( parentSection, childElement, previousParentElement, separatorElementAttributes, separatorSectionAttributes );
+        }
+      }
+    }
+
+    if ( element.nodeType == Node.COMMENT_NODE ) {
+      if ( addAttributeInElement( element, previousElement, separatorElementAttributes ) == false ) {
+        addAttributeInElement( element, section, separatorSectionAttributes );
+      }
+    }
+  }
+
+  /**
+   * Converts any current data-markdown slides in the
+   * DOM to HTML.
+   */
+  function convertSlides() {
+
+    let sections = document.querySelectorAll( '[data-markdown]');
+
+    for ( let i = 0, len = sections.length; i < len; i++ ) {
+
+      let section = sections[i];
+
+      // Only parse the same slide once
+      if ( !section.getAttribute( 'data-markdown-parsed' ) ) {
+
+        section.setAttribute( 'data-markdown-parsed', true );
+
+        let notes = section.querySelector( 'aside.notes' );
+        let markdown = getMarkdownFromSlide( section );
+
+        section.innerHTML = marked( markdown );
+        addAttributes(   section, section, null, section.getAttribute( 'data-element-attributes' ) ||
+                section.parentNode.getAttribute( 'data-element-attributes' ) ||
+                DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR,
+        section.getAttribute( 'data-attributes' ) ||
+                section.parentNode.getAttribute( 'data-attributes' ) ||
+                DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR);
+
+        // If there were notes, we need to re-add them after
+        // having overwritten the section's HTML
+        if ( notes ) {
+          section.appendChild( notes );
+        }
+
+      }
+
+    }
+
+  }
+
+  // API
+  return {
+    getMarkdownFromSlide: getMarkdownFromSlide,
+    createMarkdownSlide: createMarkdownSlide,
+    processSlides: processSlides,
+    addAttributes: addAttributes,
+    convertSlides: convertSlides
+  };
+}

+ 2 - 0
src/client/styles/scss/_admin.scss

@@ -16,6 +16,8 @@
   }
   }
 
 
   .admin-customize {
   .admin-customize {
+    @import 'hljs';
+
     .ss-container img {
     .ss-container img {
       padding: .5em;
       padding: .5em;
       background-color: #ddd;
       background-color: #ddd;

+ 35 - 0
src/client/styles/scss/_hljs.scss

@@ -0,0 +1,35 @@
+pre.hljs {
+  // override Highlight Js Style Border
+  border-radius: 3px;
+  &.hljs-no-border {
+    border: none;
+  }
+
+  position: relative;
+
+  cite {
+    position: absolute;
+    top: 0;
+    right: 0;
+    padding: 0 4px;
+    background: #ccc;
+    color: #333;
+    font-style: normal;
+    font-weight: bold;
+    opacity: 0.6;
+  }
+}
+
+// styles for highlightjs-line-numbers
+.hljs-ln td.hljs-ln-numbers {
+  user-select: none;
+
+  text-align: center;
+  color: #ccc;
+  border-right: 1px solid #CCC;
+  vertical-align: top;
+  padding-right: 5px;
+}
+.hljs-ln td.hljs-ln-code {
+  padding-left: 10px;
+}

+ 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;
     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 {
   .revision-toc {
     &.affix {
     &.affix {
       margin-top: 5px;
       margin-top: 5px;

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

@@ -40,7 +40,7 @@ body.on-edit {
   .portal-form-button,
   .portal-form-button,
   .alert-info.alert-moved,
   .alert-info.alert-moved,
   .alert-info.alert-unlinked,
   .alert-info.alert-unlinked,
-  .like-button, .bookmark-link, .btn-edit,
+  .btn-like, .btn-bookmark, .btn-edit,
   .authors,
   .authors,
   footer {
   footer {
     display: none !important;
     display: none !important;
@@ -298,6 +298,11 @@ body.on-edit {
   display: block;
   display: block;
 }
 }
 
 
+// overwrite .CodeMirror pre
+.CodeMirror pre {
+  font-family: $font-family-monospace;
+}
+
 // overwrite .CodeMirror-hints
 // overwrite .CodeMirror-hints
 .CodeMirror-hints {
 .CodeMirror-hints {
   max-height: 30em !important;
   max-height: 30em !important;

+ 0 - 9
src/client/styles/scss/_override-hljs.scss

@@ -1,9 +0,0 @@
-// override Highlight Js Style Border
-.wiki, .admin-customize {
-  pre.hljs {
-    border-radius: 3px;
-    &.hljs-no-border {
-      border: none;
-    }
-  }
-}

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

@@ -33,7 +33,7 @@
       }
       }
     }
     }
 
 
-    .like-button, .bookmark-link {
+    .btn-like, .btn-bookmark {
       border: none;
       border: none;
       font-size: 1.2em;
       font-size: 1.2em;
       line-height: 0.8em;
       line-height: 0.8em;
@@ -41,12 +41,12 @@
         background-color: transparent;
         background-color: transparent;
       }
       }
     }
     }
-    .like-button {
+    .btn-like {
       &.active {
       &.active {
         @extend .btn-info;
         @extend .btn-info;
       }
       }
     }
     }
-    .bookmark-link {
+    .btn-bookmark {
       &.active {
       &.active {
         @extend .btn-warning;
         @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 {
       &.btn-lg {
         font-size: 1.5em;
         font-size: 1.5em;
         padding: 8px;
         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;
+    }
+  }
+}

+ 5 - 30
src/client/styles/scss/_wiki.scss

@@ -2,6 +2,11 @@ div.body {
   padding: 10px;
   padding: 10px;
 }
 }
 
 
+// hljs
+.wiki {
+  @import 'hljs';
+}
+
 .wiki {
 .wiki {
   line-height: 1.8em;
   line-height: 1.8em;
   font-size: 15px;
   font-size: 15px;
@@ -108,36 +113,6 @@ div.body {
     }
     }
   }
   }
 
 
-  pre.hljs {
-    position: relative;
-
-    cite {
-      position: absolute;
-      top: 0;
-      right: 0;
-      padding: 0 4px;
-      background: #ccc;
-      color: #333;
-      font-style: normal;
-      font-weight: bold;
-      opacity: 0.6;
-    }
-  }
-
-  // styles for highlightjs-line-numbers
-  .hljs-ln td.hljs-ln-numbers {
-    user-select: none;
-
-    text-align: center;
-    color: #ccc;
-    border-right: 1px solid #CCC;
-    vertical-align: top;
-    padding-right: 5px;
-  }
-  .hljs-ln td.hljs-ln-code {
-    padding-left: 10px;
-  }
-
   p code {  // only inline code blocks
   p code {  // only inline code blocks
     font-family: $font-family-monospace-not-strictly;
     font-family: $font-family-monospace-not-strictly;
   }
   }

+ 0 - 3
src/client/styles/scss/style.scss → src/client/styles/scss/style-app.scss

@@ -8,9 +8,6 @@
 // vendor
 // vendor
 @import 'vendor';
 @import 'vendor';
 
 
-// override highlightJsStyle
-@import 'override-hljs';
-
 // override react-bootstrap-typeahead styles
 // override react-bootstrap-typeahead styles
 @import 'override-rbt';
 @import 'override-rbt';
 
 

+ 137 - 129
src/client/styles/scss/style-presentation.scss

@@ -1,129 +1,137 @@
-// import Growi variable
-@import 'variables';
-
-.reveal {
-  font-size: 32px;
-  section * {
-    font-family: Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif !important;
-  }
-
-  .slides > section {
-    //text-align: left;
-    padding: 0;
-
-    &.only.present {
-      h1, h2, h3, h4, h5, h6 {
-        font-size: 2.5em;
-      }
-    }
-
-    p {
-      line-height: 1.6;
-
-      &:first-child {
-        margin-top: 0;
-      }
-    }
-
-    pre {
-      code {
-        padding: 20px 40px;
-      }
-    }
-    blockquote {
-      width: 80%;
-      padding: 20px 60px;
-    }
-
-    ul {
-      margin-top: .2em;
-      margin-bottom: .1em;
-      > li {
-        line-height: 1.6;
-        margin-bottom: .5em;
-
-        > ul > li {
-          font-size: .85em;
-        }
-      }
-    }
-
-    h1:first-child {
-      font-size: 2.2em;
-    }
-    h2:first-child {
-      font-size: 1.8em;
-    }
-    h3, h4, h5, h6 {
-      &:first-child {
-        font-size: 1.5em;
-      }
-    }
-
-    // {{{ table (copied from bootstrap .table
-    table {
-      width: 100%;
-      margin-bottom: 1em;
-
-      border-collapse: collapse;
-      tr, td, th {
-        border-collapse: collapse;
-      }
-
-      // Cells
-      > thead,
-      > tbody,
-      > tfoot {
-        > tr {
-          > th,
-          > td {
-            padding: 1em;
-            vertical-align: top;
-            border-top: 1px solid #999;
-          }
-        }
-      }
-      // Bottom align for column headings
-      > thead > tr > th {
-        vertical-align: bottom;
-        border-bottom: 2px solid #888;
-      }
-      // Remove top border from thead by default
-      > caption + thead,
-      > colgroup + thead,
-      > thead:first-child {
-        > tr:first-child {
-          > th,
-          > td {
-            border-top: 0;
-          }
-        }
-      }
-      // Account for multiple tbody instances
-      > tbody + tbody {
-        border-top: 2px solid #888;
-      }
-
-      // .table-bordered
-      border: 1px solid #999;
-      > thead,
-      > tbody,
-      > tfoot {
-        > tr {
-          > th,
-          > td {
-            border: 1px solid #999;
-          }
-        }
-      }
-      > thead > tr {
-        > th,
-        > td {
-          border-bottom-width: 2px;
-        }
-      }
-    }
-    // }}}
-
-  }
-}
+// import Growi variable
+@import 'variables';
+
+@import "~reveal.js/css/reveal.css";
+@import "~reveal.js/css/theme/black.css";
+
+// hljs
+.reveal {
+  @import 'hljs';
+}
+
+.reveal {
+  font-size: 32px;
+  section * {
+    font-family: Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif !important;
+  }
+
+  .slides > section {
+    //text-align: left;
+    padding: 0;
+
+    &.only.present {
+      h1, h2, h3, h4, h5, h6 {
+        font-size: 2.5em;
+      }
+    }
+
+    p {
+      line-height: 1.6;
+
+      &:first-child {
+        margin-top: 0;
+      }
+    }
+
+    pre {
+      code {
+        padding: 20px 40px;
+      }
+    }
+    blockquote {
+      width: 80%;
+      padding: 20px 60px;
+    }
+
+    ul {
+      margin-top: .2em;
+      margin-bottom: .1em;
+      > li {
+        line-height: 1.6;
+        margin-bottom: .5em;
+
+        > ul > li {
+          font-size: .85em;
+        }
+      }
+    }
+
+    h1:first-child {
+      font-size: 2.2em;
+    }
+    h2:first-child {
+      font-size: 1.8em;
+    }
+    h3, h4, h5, h6 {
+      &:first-child {
+        font-size: 1.5em;
+      }
+    }
+
+    // {{{ table (copied from bootstrap .table
+    table {
+      width: 100%;
+      margin-bottom: 1em;
+
+      border-collapse: collapse;
+      tr, td, th {
+        border-collapse: collapse;
+      }
+
+      // Cells
+      > thead,
+      > tbody,
+      > tfoot {
+        > tr {
+          > th,
+          > td {
+            padding: 1em;
+            vertical-align: top;
+            border-top: 1px solid #999;
+          }
+        }
+      }
+      // Bottom align for column headings
+      > thead > tr > th {
+        vertical-align: bottom;
+        border-bottom: 2px solid #888;
+      }
+      // Remove top border from thead by default
+      > caption + thead,
+      > colgroup + thead,
+      > thead:first-child {
+        > tr:first-child {
+          > th,
+          > td {
+            border-top: 0;
+          }
+        }
+      }
+      // Account for multiple tbody instances
+      > tbody + tbody {
+        border-top: 2px solid #888;
+      }
+
+      // .table-bordered
+      border: 1px solid #999;
+      > thead,
+      > tbody,
+      > tfoot {
+        > tr {
+          > th,
+          > td {
+            border: 1px solid #999;
+          }
+        }
+      }
+      > thead > tr {
+        > th,
+        > td {
+          border-bottom-width: 2px;
+        }
+      }
+    }
+    // }}}
+
+  }
+}

+ 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) {
 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) {
   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({
   attachmentSchema = new mongoose.Schema({
     page: { type: ObjectId, ref: 'Page', index: true },
     page: { type: ObjectId, ref: 'Page', index: true },
     creator: { type: ObjectId, ref: 'User', 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 },
     fileName: { type: String, required: true },
     originalName: { type: String },
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     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;
     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) {
   attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
     var Attachment = this;
     var Attachment = this;
 
 
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
-      Attachment.getListByPageId(pageId)
+      Attachment.find({ page: pageId})
       .then((attachments) => {
       .then((attachments) => {
         for (let attachment of attachments) {
         for (let attachment of attachments) {
-          Attachment.removeAttachment(attachment).then((res) => {
+          Attachment.removeWithSubstanceById(attachment._id).then((res) => {
             // do nothing
             // do nothing
           }).catch((err) => {
           }).catch((err) => {
             debug('Attachment remove error', 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);
   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) {
   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) {
   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) {
   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 {any} page Query or Document
  * @param {string} userPublicFields string to set to select
  * @param {string} userPublicFields string to set to select
  */
  */
-const populateDataToShowRevision = (page, userPublicFields) => {
+const populateDataToShowRevision = (page, userPublicFields, imagePopulation) => {
   return page
   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;
     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;
     return this;
   }
   }
 
 
@@ -419,7 +431,7 @@ module.exports = function(crowi) {
     validateCrowi();
     validateCrowi();
 
 
     const User = crowi.model('User');
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS)
+    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS, User.IMAGE_POPULATION)
       .execPopulate();
       .execPopulate();
   };
   };
 
 
@@ -715,10 +727,12 @@ module.exports = function(crowi) {
     builder.addConditionToExcludeRedirect();
     builder.addConditionToExcludeRedirect();
     builder.addConditionToPagenate(opt.offset, opt.limit);
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
 
+    // count
     const totalCount = await builder.query.exec('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 };
     const result = { pages, totalCount, offset: opt.offset, limit: opt.limit };
     return result;
     return result;
@@ -753,12 +767,13 @@ module.exports = function(crowi) {
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
     await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
 
 
-    builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
-
+    // count
     const totalCount = await builder.query.exec('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 };
     const result = { pages, totalCount, offset: opt.offset, limit: opt.limit };
     return result;
     return result;

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

@@ -1,7 +1,6 @@
 const debug = require('debug')('growi:models:userGroup');
 const debug = require('debug')('growi:models:userGroup');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate');
 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({
 const schema = new mongoose.Schema({
   userGroupId: String,
   userGroupId: String,
-  image: String,
   name: { type: String, required: true, unique: true },
   name: { type: String, required: true, unique: true },
   createdAt: { type: Date, default: Date.now },
   createdAt: { type: Date, default: Date.now },
 });
 });
@@ -25,7 +23,7 @@ class UserGroup {
    * @memberof UserGroup
    * @memberof UserGroup
    */
    */
   static get USER_GROUP_PUBLIC_FIELDS() {
   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});
     return this.create({name: name});
   }
   }
 
 
-  /*
-   * instance methods
-   */
-
-  // グループ画像の更新
-  updateImage(image) {
-    this.image = image;
-    return this.save();
-  }
-
-  // グループ画像の削除
-  deleteImage() {
-    return this.updateImage(null);
-  }
-
   // グループ名の更新
   // グループ名の更新
   updateName(name) {
   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) {
 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 userSchema;
   let userEvent;
   let userEvent;
@@ -34,6 +36,7 @@ module.exports = function(crowi) {
   userSchema = new mongoose.Schema({
   userSchema = new mongoose.Schema({
     userId: String,
     userId: String,
     image: String,
     image: String,
+    imageAttachment: { type: ObjectId, ref: 'Attachment' },
     isGravatarEnabled: { type: Boolean, default: false },
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },
     isEmailPublished: { type: Boolean, default: true },
     googleId: String,
     googleId: String,
@@ -133,6 +136,10 @@ module.exports = function(crowi) {
     return lang;
     return lang;
   }
   }
 
 
+  userSchema.methods.populateImage = async function() {
+    return await this.populate(IMAGE_POPULATION);
+  };
+
   userSchema.methods.isPasswordSet = function() {
   userSchema.methods.isPasswordSet = function() {
     if (this.password) {
     if (this.password) {
       return true;
       return true;
@@ -170,7 +177,6 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-
   userSchema.methods.updateIsEmailPublished = function(isEmailPublished, callback) {
   userSchema.methods.updateIsEmailPublished = function(isEmailPublished, callback) {
     this.isEmailPublished = isEmailPublished;
     this.isEmailPublished = isEmailPublished;
     this.save(function(err, userData) {
     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) {
   userSchema.methods.updateGoogleId = function(googleId, callback) {
@@ -367,55 +382,33 @@ module.exports = function(crowi) {
   };
   };
 
 
   userSchema.statics.findAllUsers = function(option) {
   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)) {
     if (!Array.isArray(status)) {
       status = [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) {
   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
       , status = option.status || STATUS_ACTIVE
       , fields = option.fields || USER_PUBLIC_FIELDS
       , 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) {
   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) {
   userSchema.statics.getUsernameByPath = function(path) {
     var username = null;
     var username = null;
     if (m = path.match(/^\/user\/([^\/]+)\/?/)) {
     if (m = path.match(/^\/user\/([^\/]+)\/?/)) {
@@ -832,6 +819,7 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_DELETED     = STATUS_DELETED;
   userSchema.statics.STATUS_DELETED     = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED     = STATUS_INVITED;
   userSchema.statics.STATUS_INVITED     = STATUS_INVITED;
   userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
   userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
+  userSchema.statics.IMAGE_POPULATION   = IMAGE_POPULATION;
   userSchema.statics.PAGE_ITEMS         = PAGE_ITEMS;
   userSchema.statics.PAGE_ITEMS         = PAGE_ITEMS;
 
 
   userSchema.statics.LANG_EN            = LANG_EN;
   userSchema.statics.LANG_EN            = LANG_EN;

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

@@ -787,120 +787,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);
   // app.post('/_api/admin/user-group/delete' , admin.userGroup.removeCompletely);
   actions.userGroup.removeCompletely = function(req, res) {
   actions.userGroup.removeCompletely = function(req, res) {
     const id = req.body.user_group_id;
     const id = req.body.user_group_id;
 
 
-    const fileUploader = require('../service/file-uploader')(crowi, app);
-
     UserGroup.removeCompletelyById(id)
     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(() => {
       .then(() => {
         req.flash('successMessage', '削除しました');
         req.flash('successMessage', '削除しました');
         return res.redirect('/admin/user-groups');
         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) {
 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;
   actions.api = api;
 
 
-  api.download = function(req, res) {
+  api.download = async function(req, res) {
     const id = req.params.id;
     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
    * @apiName get
    * @apiGroup Attachment
    * @apiGroup Attachment
    *
    *
-   * @apiParam {String} pageId, fileName
+   * @apiParam {String} id
    */
    */
   api.get = async function(req, res) {
   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') {
     if (process.env.FILE_UPLOAD !== 'mongodb') {
       return res.status(400);
       return res.status(400);
     }
     }
+
     const pageId = req.params.pageId;
     const pageId = req.params.pageId;
     const fileName = req.params.fileName;
     const fileName = req.params.fileName;
     const filePath = `attachment/${pageId}/${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
    * @apiParam {String} page_id
    */
    */
-  api.list = function(req, res) {
+  api.list = async function(req, res) {
     const id = req.query.page_id || null;
     const id = req.query.page_id || null;
     if (!id) {
     if (!id) {
       return res.json(ApiResponse.error('Parameters page_id is required.'));
       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
    * @apiParam {File} file
    */
    */
   api.add = async function(req, res) {
   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.'));
       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
    * @apiParam {String} attachment_id
    */
    */
-  api.remove = function(req, res) {
+  api.remove = async function(req, res) {
     const id = req.body.attachment_id;
     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;
   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.'));
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
     }
 
 
-    let comments = null;
+    let fetcher = null;
 
 
     try {
     try {
       if (revisionId) {
       if (revisionId) {
-        comments = await Comment.getCommentsByRevisionId(revisionId);
+        fetcher = Comment.getCommentsByRevisionId(revisionId);
       }
       }
       else {
       else {
-        comments = await Comment.getCommentsByPageId(pageId);
+        fetcher = Comment.getCommentsByPageId(pageId);
       }
       }
     }
     }
     catch (err) {
     catch (err) {
       return res.json(ApiResponse.error(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}));
     res.json(ApiResponse.success({comments}));
   };
   };
 
 

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

@@ -7,7 +7,6 @@ const axios = require('axios');
 const ApiResponse = require('../util/apiResponse');
 const ApiResponse = require('../util/apiResponse');
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
-  const config = crowi.getConfig();
   const Page = crowi.models.Page;
   const Page = crowi.models.Page;
   const pageEvent = crowi.event('page');
   const pageEvent = crowi.event('page');
 
 
@@ -72,7 +71,7 @@ module.exports = function(crowi, app) {
 
 
     // generate definitions to replace
     // generate definitions to replace
     const definitions = {
     const definitions = {
-      styles,
+      styles: escape(styles),
     };
     };
     // inject
     // inject
     const script = stylesScriptContentTpl(definitions);
     const script = stylesScriptContentTpl(definitions);

+ 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) {
 module.exports = function(crowi, app) {
   const middleware = require('../util/middlewares')
   const middleware = require('../util/middlewares')
-    , multer    = require('multer')
     , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , form      = require('../form')
     , form      = require('../form')
     , page      = require('./page')(crowi, app)
     , 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.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/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/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('/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
   // user-group-relations admin
   app.post('/admin/user-group-relation/create', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.create);
   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/password'             , form.me.password          , loginRequired(crowi, app) , me.password);
   app.post('/me/imagetype'            , form.me.imagetype         , loginRequired(crowi, app) , me.imagetype);
   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/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.post('/me/auth/google'          , loginRequired(crowi, app) , me.authGoogle);
   app.get( '/me/auth/google/callback' , loginRequired(crowi, app) , me.authGoogleCallback);
   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( '/: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( '/_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( '/_search'                 , loginRequired(crowi, app, false) , search.searchPage);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);
 
 
   app.get( '/_api/check_username'           , user.api.checkUsername);
   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/me/user-group-relations'  , accessTokenParser , loginRequired(crowi, app) , me.api.userGroupRelations);
   app.get( '/_api/user/bookmarks'           , loginRequired(crowi, app, false) , user.api.bookmarks);
   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.add'          , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.like);
   app.post('/_api/likes.remove'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.unlike);
   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.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.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.get'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.get);
   app.get( '/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.ids);
   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);
                 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 {
             else {
               // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
               // 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;
   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
    * retrieve user-group-relation documents
    * @param {object} req
    * @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) {
   actions.authGoogle = function(req, res) {
     var googleAuth = require('../util/googleAuth')(crowi);
     var googleAuth = require('../util/googleAuth')(crowi);
 
 

+ 10 - 8
src/server/routes/page.js

@@ -165,7 +165,9 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   async function addRenderVarsForUserPage(renderVars, page, requestUser) {
   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) {
     if (userData != null) {
       renderVars.pageUser = userData;
       renderVars.pageUser = userData;
       renderVars.bookmarkList = await Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: requestUser});
       renderVars.bookmarkList = await Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: requestUser});
@@ -233,14 +235,14 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   async function showPageListForCrowiBehavior(req, res, next) {
   async function showPageListForCrowiBehavior(req, res, next) {
-    const path = Page.addSlashOfEnd(getPathFromRequest(req));
+    const portalPath = Page.addSlashOfEnd(getPathFromRequest(req));
     const revisionId = req.query.revision;
     const revisionId = req.query.revision;
 
 
     // check whether this page has portal page
     // check whether this page has portal page
-    const portalPageStatus = await getPortalPageState(path, req.user);
+    const portalPageStatus = await getPortalPageState(portalPath, req.user);
 
 
     let view = 'customlayout-selector/page_list';
     let view = 'customlayout-selector/page_list';
-    const renderVars = { path };
+    const renderVars = { path: portalPath };
 
 
     if (portalPageStatus === PORTAL_STATUS_FORBIDDEN) {
     if (portalPageStatus === PORTAL_STATUS_FORBIDDEN) {
       // inject to req
       // inject to req
@@ -248,7 +250,7 @@ module.exports = function(crowi, app) {
       view = 'customlayout-selector/forbidden';
       view = 'customlayout-selector/forbidden';
     }
     }
     else if (portalPageStatus === PORTAL_STATUS_EXISTS) {
     else if (portalPageStatus === PORTAL_STATUS_EXISTS) {
-      let portalPage = await Page.findByPathAndViewer(path, req.user);
+      let portalPage = await Page.findByPathAndViewer(portalPath, req.user);
       portalPage.initLatestRevisionField(revisionId);
       portalPage.initLatestRevisionField(revisionId);
 
 
       // populate
       // populate
@@ -261,7 +263,7 @@ module.exports = function(crowi, app) {
     const limit = 50;
     const limit = 50;
     const offset = parseInt(req.query.offset)  || 0;
     const offset = parseInt(req.query.offset)  || 0;
 
 
-    await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit);
+    await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);
@@ -280,7 +282,7 @@ module.exports = function(crowi, app) {
     }
     }
     else if (page.redirectTo) {
     else if (page.redirectTo) {
       debug(`Redirect to '${page.redirectTo}'`);
       debug(`Redirect to '${page.redirectTo}'`);
-      return res.redirect(encodeURI(page.redirectTo + '?redirectFrom=' + pagePathUtils.encodePagePath(page.path)));
+      return res.redirect(encodeURI(page.redirectTo + '?redirectFrom=' + pagePathUtils.encodePagePath(path)));
     }
     }
 
 
     logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, page.path);
     logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, page.path);
@@ -381,7 +383,7 @@ module.exports = function(crowi, app) {
 
 
       if (hasPortalPage) {
       if (hasPortalPage) {
         logger.debug('The portal page is found', portalPagePath);
         logger.debug('The portal page is found', portalPagePath);
-        return res.redirect(portalPagePath);
+        return res.redirect(encodeURI(portalPagePath + '?redirectFrom=' + pagePathUtils.encodePagePath(req.path)));
       }
       }
     }
     }
 
 

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

@@ -44,10 +44,10 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} user_ids
    * @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) {
     if (!userIds || userIds.split(',').length <= 0) {
       userFetcher = User.findAllUsers();
       userFetcher = User.findAllUsers();
     }
     }
@@ -55,25 +55,22 @@ module.exports = function(crowi, app) {
       userFetcher = User.findUsersByIds(userIds.split(','));
       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
         // omit email
         if (true !== user.isEmailPublished) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
         if (true !== user.isEmailPublished) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
           user.email = undefined;
           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.error(err));
-    });
+    }
+
+    return res.json(ApiResponse.success(data));
   };
   };
 
 
   return actions;
   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) {
 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() {
   function S3Factory() {
     const awsConfig = getAwsConfig();
     const awsConfig = getAwsConfig();
@@ -36,129 +36,77 @@ module.exports = function(crowi) {
     return new aws.S3();
     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 s3 = S3Factory();
     const awsConfig = getAwsConfig();
     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 {
     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) {
 module.exports = function(crowi) {
   'use strict';
   'use strict';
 
 
-  const debug = require('debug')('growi:service:fileUploaderGridfs');
-  const mongoose = require('mongoose');
-  const path = require('path');
-  const fs = require('fs');
   const lib = {};
   const lib = {};
 
 
   // instantiate mongoose-gridfs
   // instantiate mongoose-gridfs
@@ -20,29 +18,17 @@ module.exports = function(crowi) {
   const AttachmentFile = gridfs.model;
   const AttachmentFile = gridfs.model;
   const Chunks = mongoose.model('Chunks', gridfs.schema, 'attachmentFiles.chunks');
   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) {
       if (error) {
         throw new Error(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);
     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;
   return lib;

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

@@ -2,7 +2,9 @@ const envToModuleMappings = {
   aws:     'aws',
   aws:     'aws',
   local:   'local',
   local:   'local',
   none:    'none',
   none:    'none',
+  mongo:   'gridfs',
   mongodb: 'gridfs',
   mongodb: 'gridfs',
+  gridfs:  'gridfs',
 };
 };
 
 
 class FileUploaderFactory {
 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) {
 module.exports = function(crowi) {
   'use strict';
   '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) {
     passport.serializeUser(function(user, done) {
       done(null, user.id);
       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;
     this.isSerializerSetup = true;

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

@@ -1,4 +1,5 @@
 const debug = require('debug')('growi:lib:middlewares');
 const debug = require('debug')('growi:lib:middlewares');
+const logger = require('@alias/logger')('growi:lib:middlewares');
 const md5 = require('md5');
 const md5 = require('md5');
 const entities = require('entities');
 const entities = require('entities');
 
 
@@ -15,27 +16,23 @@ exports.csrfKeyGenerator = function(crowi, app) {
 };
 };
 
 
 exports.loginChecker = 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;
       res.locals.user = req.user;
       next();
       next();
     }
     }
+    catch (err) {
+      next(err);
+    }
   };
   };
 };
 };
 
 
@@ -62,7 +59,7 @@ exports.csrfVerify = function(crowi, app) {
       return next();
       return next();
     }
     }
 
 
-    debug('csrf verification failed. return 403', csrfKey, token);
+    logger.warn('csrf verification failed. return 403', csrfKey, token);
     return res.sendStatus(403);
     return res.sendStatus(403);
   };
   };
 };
 };
@@ -88,6 +85,9 @@ exports.swigFilters = function(crowi, app, swig) {
     if (user.image) {
     if (user.image) {
       return user.image;
       return user.image;
     }
     }
+    else if (user.imageAttachment != null) {
+      return user.imageAttachment.filePathProxied;
+    }
     else {
     else {
       return '/images/icons/user.svg';
       return '/images/icons/user.svg';
     }
     }
@@ -156,7 +156,6 @@ exports.swigFilters = function(crowi, app, swig) {
     swig.setFilter('presentation', function(string) {
     swig.setFilter('presentation', function(string) {
       // 手抜き
       // 手抜き
       return string
       return string
-        .replace(/[\n]+#/g, '\n\n\n#')
         .replace(/\s(https?.+(jpe?g|png|gif))\s/, '\n\n\n![]($1)\n\n\n');
         .replace(/\s(https?.+(jpe?g|png|gif))\s/, '\n\n\n![]($1)\n\n\n');
     });
     });
 
 

+ 3 - 7
src/server/views/admin/customize.html

@@ -2,22 +2,18 @@
 
 
 {% block html_title %}{{ customTitle(t('Customize')) }} {% endblock %}
 {% block html_title %}{{ customTitle(t('Customize')) }} {% endblock %}
 
 
-{% block style_css_block %}
+{% block theme_css_block %}
   {% if 'kibela' === layoutType() %}
   {% if 'kibela' === layoutType() %}
     {% if env === 'development' %}
     {% if env === 'development' %}
-      <script src="{{ webpack_asset('styles/style.js') }}"></script>
       <script src="{{ webpack_asset('styles/theme-kibela.js') }}"></script>
       <script src="{{ webpack_asset('styles/theme-kibela.js') }}"></script>
     {% else %}
     {% else %}
-      <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
-      <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-kibela.css') }}">
+      <link rel="stylesheet" href="{{ webpack_asset('styles/theme-kibela.css') }}">
     {% endif %}
     {% endif %}
   {% else %}
   {% else %}
     {% if env === 'development' %}
     {% if env === 'development' %}
-      <script src="{{ webpack_asset('styles/style.js') }}"></script>
       <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
       <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
     {% else %}
     {% else %}
-      <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
-      <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
+    <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
     {% endif %}
     {% endif %}
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}

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

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

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

@@ -24,23 +24,14 @@
         <i class="icon-like"></i> {{ t('Like!') }}
         <i class="icon-like"></i> {{ t('Like!') }}
       </dt>
       </dt>
       <dd>
       <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>
       </dd>
 
 
       <dt><i class="fa fa-paw"></i> {{ t('Seen by') }}</dt>
       <dt><i class="fa fa-paw"></i> {{ t('Seen by') }}</dt>
       <dd>
       <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>
       </dd>
     </dl>
     </dl>
   </div>
   </div>

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

@@ -26,7 +26,8 @@
 
 
     {# relocate #revision-toc #}
     {# relocate #revision-toc #}
     <div class="col-lg-2 col-md-3 revision-toc-container hidden-sm hidden-xs">
     <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 id="revision-toc-content" class="revision-toc-content"></div>
       </div>
       </div>
     </div> {# /.col- #}
     </div> {# /.col- #}

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

@@ -26,7 +26,8 @@
 
 
     {# relocate #revision-toc #}
     {# relocate #revision-toc #}
     <div class="col-lg-2 col-md-3 revision-toc-container hidden-sm hidden-xs">
     <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 id="revision-toc-content" class="revision-toc-content"></div>
       </div>
       </div>
     </div> {# /.col- #}
     </div> {# /.col- #}

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

@@ -44,7 +44,13 @@
 
 
     {# relocate #revision-toc #}
     {# relocate #revision-toc #}
     <div class="col-lg-2 col-md-3 revision-toc-container hidden-sm hidden-xs">
     <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 id="revision-toc-content" class="revision-toc-content"></div>
       </div>
       </div>
     </div> {# /.col- #}
     </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>

+ 9 - 4
src/server/views/layout/layout.html

@@ -71,20 +71,25 @@
 
 
   <!-- styles -->
   <!-- styles -->
   {% block style_css_block %}
   {% block style_css_block %}
+    {% if env === 'development' %}
+      <script src="{{ webpack_asset('styles/style-commons.js') }}"></script>
+      <script src="{{ webpack_asset('styles/style-app.js') }}"></script>
+    {% else %}
+      <script src="{{ webpack_asset('styles/style-commons.js') }}"></script>
+      <link rel="stylesheet" href="{{ webpack_asset('styles/style-app.css') }}">
+    {% endif %}
+  {% endblock %}
+  {% block theme_css_block %}
     {% if 'kibela' === layoutType() %}
     {% if 'kibela' === layoutType() %}
       {% if env === 'development' %}
       {% if env === 'development' %}
-        <script src="{{ webpack_asset('styles/style.js') }}"></script>
         <script src="{{ webpack_asset('styles/theme-kibela.js') }}"></script>
         <script src="{{ webpack_asset('styles/theme-kibela.js') }}"></script>
       {% else %}
       {% else %}
-        <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
         <link rel="stylesheet" href="{{ webpack_asset('styles/theme-kibela.css') }}">
         <link rel="stylesheet" href="{{ webpack_asset('styles/theme-kibela.css') }}">
       {% endif %}
       {% endif %}
     {% else %}
     {% else %}
       {% if env === 'development' %}
       {% if env === 'development' %}
-        <script src="{{ webpack_asset('styles/style.js') }}"></script>
         <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
         <script src="{{ webpack_asset('styles/theme-' + theme() + '.js') }}"></script>
       {% else %}
       {% else %}
-        <link rel="stylesheet" href="{{ webpack_asset('styles/style.css') }}">
         <link rel="stylesheet" href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
         <link rel="stylesheet" href="{{ webpack_asset('styles/theme-' + theme() + '.css') }}">
       {% endif %}
       {% endif %}
     {% endif %}
     {% endif %}

+ 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>
             <img src="{{ user|uploadedpicture }}" class="picture picture-lg img-circle" id="settingUserPicture"><br>
             </p>
             </p>
             <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>
               <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
             </form>
             </form>
-            {% endif %}
             </p>
             </p>
           </div>
           </div>
         </div><!-- /.form-group -->
         </div><!-- /.form-group -->
@@ -172,8 +173,9 @@
           </label>
           </label>
           <div class="col-sm-8">
           <div class="col-sm-8">
             {% if isUploadable() %}
             {% 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 id="pictureUploadFormProgress" class="d-flex align-items-center">
               </div>
               </div>
             </form>
             </form>
@@ -196,39 +198,69 @@
   </div><!-- /.form-box -->
   </div><!-- /.form-box -->
 
 
   <script>
   <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() == '') {
       if ($(this).val() == '') {
         return false;
         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> アップロード中...');
       $('#pictureUploadFormProgress').html('<div class="speeding-wheel-sm m-r-5"></div> アップロード中...');
       $.ajax($form.attr("action"), {
       $.ajax($form.attr("action"), {
         type: 'post',
         type: 'post',
         processData: false,
         processData: false,
         contentType: 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;
       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>
   </script>
 
 
   {% if googleLoginEnabled() %}
   {% if googleLoginEnabled() %}

+ 1 - 1
src/server/views/modal/unportalize.html

@@ -37,7 +37,7 @@
             <div>
             <div>
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="path" value="{{ page.path }}">
               <input type="hidden" name="path" value="{{ page.path }}">
-              <input type="hidden" class="form-control" name="q" id="newPageName" value="{{ unportalizedPath }}">
+              <input type="hidden" name="new_path" value="{{ unportalizedPath }}">
               <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
               <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
               <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
               <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
               <button type="submit" class="btn btn-warning">Unportalize</button>
               <button type="submit" class="btn btn-warning">Unportalize</button>

+ 3 - 4
src/server/views/page_presentation.html

@@ -27,13 +27,12 @@
     {% endif %}
     {% endif %}
 
 
     <script src="{{ webpack_asset('js/legacy-presentation.js') }}" defer></script>
     <script src="{{ webpack_asset('js/legacy-presentation.js') }}" defer></script>
+    <link rel="stylesheet" href="{{ webpack_asset('styles/style-presentation.css') }}">
 
 
     <title>{{ path|path2name }} | {{ path }}</title>
     <title>{{ path|path2name }} | {{ path }}</title>
 
 
-    <!-- styles -->
-    <link rel="stylesheet" href="{{ webpack_asset('styles/style-presentation.css') }}">
-
     {{ cdnStyleTagsByGroup('basis') }}
     {{ cdnStyleTagsByGroup('basis') }}
+    {{ cdnHighlightJsStyleTag(highlightJsStyle()) }}
 
 
     <style>
     <style>
       {{ customCss() }}
       {{ customCss() }}
@@ -48,7 +47,7 @@
         {% if 3 === pageBreakSeparator %}
         {% if 3 === pageBreakSeparator %}
           {% set dataSeparator = pageBreakCustomSeparator %}
           {% set dataSeparator = pageBreakCustomSeparator %}
         {% elseif 2 === pageBreakSeparator %}
         {% elseif 2 === pageBreakSeparator %}
-          {% set dataSeparator = "^-----$" %}
+          {% set dataSeparator = "\n-----\n" %}
         {% else %}
         {% else %}
           {% set dataSeparator = "\n\n\n" %}
           {% set dataSeparator = "\n\n\n" %}
         {% endif %}
         {% endif %}

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

+ 3 - 6
src/server/views/widget/page_alerts.html

@@ -34,14 +34,11 @@
     </div>
     </div>
     {% endif %}
     {% endif %}
 
 
-    {% if not page.isDeleted() and (req.query.renamed or req.query.redirectFrom) %}
+    {% if not page.isDeleted() and (redirectFrom or req.query.renamed or req.query.redirectFrom) %}
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
       <span>
       <span>
-        {% if req.query.renamed %}
-          <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.renamed)) }}
-        {% else %}
-          <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.redirectFrom)) }}
-        {% endif %}
+        {% set fromPath = req.query.renamed or req.query.redirectFrom %}
+        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(fromPath)) }}
       </span>
       </span>
       {% if user %}
       {% if user %}
       <form role="form" id="unlink-page-form" onsubmit="return false;">
       <form role="form" id="unlink-page-form" onsubmit="return false;">

+ 5 - 2
src/server/views/widget/page_tabs.html

@@ -37,8 +37,11 @@
       <ul class="dropdown-menu">
       <ul class="dropdown-menu">
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>
         {% if ('/' !== path) %}
         {% if ('/' !== path) %}
-        <li class="divider"></li>
-        <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> {{ t('Unportalize') }}</a></li>
+          <li class="divider"></li>
+          <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> {{ t('Unportalize') }}</a></li>
+          {% if isDeletablePage() %}
+            <li><a href="#" data-target="#deletePage" data-toggle="modal"><i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}</a></li>
+          {% endif %}
         {% endif %}
         {% endif %}
       </ul>
       </ul>
     </li>
     </li>

Fișier diff suprimat deoarece este prea mare
+ 388 - 272
yarn.lock


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff