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

Merge official crowi v1.6.1

# Conflicts:
#	CHANGES.md
#	README.md
#	lib/models/config.js
#	lib/models/page.js
#	lib/views/layout/layout.html
#	lib/views/page.html
#	lib/views/user_page.html
#	npm-shrinkwrap.json
#	package.json
#	resource/css/crowi.scss
#	resource/js/app.js
#	resource/js/components/HeaderSearchBox.js
#	resource/js/components/HeaderSearchBox/SearchForm.js
#	resource/js/components/Page/PageBody.js
#	resource/js/components/Page/PagePath.js
#	resource/js/components/User/UserPicture.js
#	resource/js/legacy/crowi-form.js
#	resource/js/util/Crowi.js
#	resource/js/util/CrowiRenderer.js
Yuki Takei 9 лет назад
Родитель
Сommit
8f8d896e6f
62 измененных файлов с 1281 добавлено и 296 удалено
  1. 1 0
      .babelrc
  2. 1 0
      lib/crowi/express-init.js
  3. 2 1
      lib/form/admin/app.js
  4. 66 29
      lib/models/attachment.js
  5. 31 2
      lib/models/config.js
  6. 12 1
      lib/models/page.js
  7. 66 10
      lib/routes/attachment.js
  8. 1 0
      lib/routes/index.js
  9. 59 32
      lib/routes/page.js
  10. 1 1
      lib/views/_form.html
  11. 20 0
      lib/views/admin/app.html
  12. 4 0
      lib/views/layout/layout.html
  13. 1 7
      lib/views/modal/rename.html
  14. 14 8
      lib/views/page.html
  15. 1 1
      lib/views/page_list.html
  16. 1 1
      lib/views/user_page.html
  17. 110 12
      local_modules/crowi-fileupload-aws/index.js
  18. 5 1
      local_modules/crowi-fileupload-local/index.js
  19. 6 0
      locales/en-US/translation.json
  20. 6 0
      locales/ja/translation.json
  21. 4 3
      package.json
  22. 30 0
      resource/css/_attachments.scss
  23. 9 0
      resource/css/_user.scss
  24. 6 2
      resource/css/crowi.scss
  25. 19 5
      resource/js/app.js
  26. 67 0
      resource/js/components/BookmarkButton.js
  27. 10 3
      resource/js/components/Common/Icon.js
  28. 69 0
      resource/js/components/Common/Modal.js
  29. 4 3
      resource/js/components/Common/UserDate.js
  30. 1 1
      resource/js/components/HeaderSearchBox.js
  31. 1 0
      resource/js/components/HeaderSearchBox/SearchForm.js
  32. 4 3
      resource/js/components/HeaderSearchBox/SearchSuggest.js
  33. 4 3
      resource/js/components/Page/PageBody.js
  34. 65 0
      resource/js/components/Page/PagePath.js
  35. 114 0
      resource/js/components/PageAttachment.js
  36. 62 0
      resource/js/components/PageAttachment/Attachment.js
  37. 81 0
      resource/js/components/PageAttachment/DeleteAttachmentModal.js
  38. 30 0
      resource/js/components/PageAttachment/PageAttachmentList.js
  39. 3 2
      resource/js/components/PageHistory.js
  40. 4 3
      resource/js/components/PageHistory/PageRevisionList.js
  41. 3 2
      resource/js/components/PageHistory/Revision.js
  42. 4 3
      resource/js/components/PageHistory/RevisionDiff.js
  43. 3 2
      resource/js/components/PageList/ListView.js
  44. 3 2
      resource/js/components/PageList/Page.js
  45. 2 1
      resource/js/components/PageList/PageListMeta.js
  46. 2 1
      resource/js/components/PageList/PagePath.js
  47. 12 22
      resource/js/components/PageListSearch.js
  48. 4 5
      resource/js/components/SearchPage.js
  49. 2 1
      resource/js/components/SearchPage/SearchForm.js
  50. 5 4
      resource/js/components/SearchPage/SearchResult.js
  51. 3 2
      resource/js/components/SearchPage/SearchResultList.js
  52. 1 8
      resource/js/components/SeenUserList.js
  53. 3 1
      resource/js/components/SeenUserList/UserList.js
  54. 41 0
      resource/js/components/User/User.js
  55. 3 2
      resource/js/components/User/UserPicture.js
  56. 16 3
      resource/js/legacy/crowi-form.js
  57. 4 72
      resource/js/legacy/crowi.js
  58. 30 3
      resource/js/util/Crowi.js
  59. 16 1
      resource/js/util/CrowiRenderer.js
  60. 0 0
      resource/js/util/PostProcessor/Emoji.js
  61. 15 0
      test/models/page.test.js
  62. 114 27
      yarn.lock

+ 1 - 0
.babelrc

@@ -1,5 +1,6 @@
 {
   "presets": [
+    "env",
     "es2015",
     "react"
   ]

+ 1 - 0
lib/crowi/express-init.js

@@ -73,6 +73,7 @@ module.exports = function(crowi, app) {
         language:   User.getLanguageLabels(),
         registrationMode: Config.getRegistrationModeLabels(),
     };
+    res.locals.local_config = Config.getLocalconfig(config); // config for browser context
 
     next();
   });

+ 2 - 1
lib/form/admin/app.js

@@ -5,6 +5,7 @@ var form = require('express-form')
 
 module.exports = form(
   field('settingForm[app:title]').required(),
-  field('settingForm[app:confidential]')
+  field('settingForm[app:confidential]'),
+  field('settingForm[app:fileUpload]').isInt()
 );
 

+ 66 - 29
lib/models/attachment.js

@@ -24,11 +24,11 @@ module.exports = function(crowi) {
   }, {
     toJSON: {
       virtuals: true
-    }
+    },
   });
 
   attachmentSchema.virtual('fileUrl').get(function() {
-    return fileUploader.generateUrl(this.filePath);
+    return `/files/${this._id}`;
   });
 
   attachmentSchema.statics.findById = function(id) {
@@ -99,8 +99,30 @@ module.exports = function(crowi) {
     });
   };
 
+  attachmentSchema.statics.guessExtByFileType = function (fileType) {
+    let ext = '';
+    const isImage = fileType.match(/^image\/(.+)/i);
+
+    if (isImage) {
+      ext = isImage[1].toLowerCase();
+    }
+
+    return ext;
+  };
+
   attachmentSchema.statics.createAttachmentFilePath = function (pageId, fileName, fileType) {
-    var ext = '.' + fileName.match(/(.*)(?:\.([^.]+$))/)[2] || '';
+    const Attachment = this;
+    let ext = '';
+    const fnExt = fileName.match(/(.*)(?:\.([^.]+$))/);
+
+    if (fnExt) {
+      ext = '.' + fnExt[2];
+    } else {
+      ext = Attachment.guessExtByFileType(fileType);
+      if (ext !== '') {
+        ext = '.' + ext;
+      }
+    }
 
     return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
   };
@@ -108,37 +130,52 @@ module.exports = function(crowi) {
   attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
     var Attachment = this;
 
-    // todo
-    return Promise.resolve();
-    //return new Promise(function(resolve, reject) {
-    //  // target find
-    //  Attachment.getListByPageId(pageId)
-    //  .then(function(attachments) {
-    //  }).then(function(done) {
-    //  }).catch(function(err) {
-    //  });
-    //});
+    return new Promise((resolve, reject) => {
+      Attachment.getListByPageId(pageId)
+      .then((attachments) => {
+        for (attachment of attachments) {
+          Attachment.removeAttachment(attachment).then((res) => {
+            // do nothing
+          }).catch((err) => {
+            debug('Attachment remove error', err);
+          });
+        }
+
+        resolve(attachments);
+      }).catch((err) => {
+        reject(err);
+      });
+    });
 
   };
 
-  attachmentSchema.statics.createCacheFileName = function(attachment) {
-    return crowi.cacheDir + 'attachment-' + attachment._id;
+  attachmentSchema.statics.findDeliveryFile = function(attachment, forceUpdate) {
+    var Attachment = this;
+
+    // TODO
+    var forceUpdate = forceUpdate || false;
+
+    return fileUploader.findDeliveryFile(attachment._id, attachment.filePath);
   };
 
-  attachmentSchema.statics.findDeliveryFile = function(attachment) {
-    // find local
-    var fs = require('fs');
-    var deliveryFile = {
-      filename: '',
-      options: {
-        headers: {
-          'Content-Type': attachment.fileFormat,
-        },
-      },
-    };
-    var cacheFileName = this.createCacheFileName(attachment);
-    // とちゅう
-    return deliveryFile;
+  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);
+        });
+      });
+    });
   };
 
   return mongoose.model('Attachment', attachmentSchema);

+ 31 - 2
lib/models/config.js

@@ -24,6 +24,8 @@ module.exports = function(crowi) {
       'app:title'         : 'Crowi',
       'app:confidential'  : '',
 
+      'app:fileUpload'    : false,
+
       'security:registrationMode'      : 'Open',
       'security:registrationWhiteList' : [],
 
@@ -38,8 +40,6 @@ module.exports = function(crowi) {
       'mail:smtpUser'     : '',
       'mail:smtpPassword' : '',
 
-      'searcher:url': '',
-
       'google:clientId'     : '',
       'google:clientSecret' : '',
 
@@ -253,6 +253,17 @@ module.exports = function(crowi) {
     return this.uglifiedCustomCss;
   }
 
+  configSchema.statics.fileUploadEnabled = function(config)
+  {
+    const Config = this;
+
+    if (!Config.isUploadable(config)) {
+      return false;
+    }
+
+    return config.crowi['app:fileUpload'] || false;
+  };
+
   configSchema.statics.hasSlackConfig = function(config)
   {
     if (!config.notification) {
@@ -279,6 +290,24 @@ module.exports = function(crowi) {
     return true;
   };
 
+  configSchema.statics.getLocalconfig = function(config)
+  {
+    const Config = this;
+
+    const local_config = {
+      crowi: {
+        title: config.crowi['app:title'],
+        url: config.crowi['app:url'] || '',
+      },
+      upload: {
+        image: Config.isUploadable(config),
+        file: Config.fileUploadEnabled(config),
+      },
+    };
+
+    return local_config;
+  }
+
   /*
   configSchema.statics.isInstalled = function(config)
   {

+ 12 - 1
lib/models/page.js

@@ -342,6 +342,8 @@ module.exports = function(crowi) {
       path = '/' + path;
     }
 
+    path = path.replace(/\/\s+?/g, '/').replace(/\s+\//g, '/');
+
     return path;
   };
 
@@ -384,6 +386,7 @@ module.exports = function(crowi) {
       /^\/user\/[^\/]+\/(bookmarks|comments|activities|pages|recent-create|recent-edit)/, // reserved
       /^\/?https?:\/\/.+$/, // avoid miss in renaming
       /\/{2,}/,             // avoid miss in renaming
+      /\s+\/\s+/,           // avoid miss in renaming
       /.+\/edit$/,
       /.+\.md$/,
       /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments)(\/.*|$)/,
@@ -555,7 +558,15 @@ module.exports = function(crowi) {
     var User = crowi.model('User');
     var limit = option.limit || 50;
     var offset = option.offset || 0;
-    var conditions = { creator: user._id, redirectTo: null };
+    var conditions = {
+      creator: user._id,
+      redirectTo: null,
+      $or: [
+        {status: null},
+        {status: STATUS_PUBLISHED},
+      ],
+    };
+
     if (!user.equals(currentUser._id)) {
       conditions.grant = GRANT_PUBLIC;
     }

+ 66 - 10
lib/routes/attachment.js

@@ -14,19 +14,38 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
-  api.redirector = function(req, res){
+  api.redirector = function(req, res, next){
     var id = req.params.id;
 
     Attachment.findById(id)
     .then(function(data) {
 
       // TODO: file delivery plugin for cdn
-      var deliveryFile = Attachment.findDeliveryFile(data);
-      return res.sendFile(deliveryFile.filename, deliveryFile.options);
-    }).catch(function(err) {
-
+      Attachment.findDeliveryFile(data)
+      .then(fileName => {
+
+        var deliveryFile = {
+          fileName: fileName,
+          options: {
+            headers: {
+              'Content-Type': data.fileFormat,
+            },
+          },
+        };
+
+        if (deliveryFile.fileName.match(/^\/uploads/)) {
+          debug('Using loacal file module, just redirecting.')
+          return res.redirect(deliveryFile.fileName);
+        } else {
+          return res.sendFile(deliveryFile.fileName, deliveryFile.options);
+        }
+      }).catch(err => {
+        //debug('error', err);
+      });
+    }).catch((err) => {
+      //debug('err', err);
       // not found
-      return res.sendFile(crowi.publicDir + '/images/file-not-found.png');
+      return res.status(404).sendFile(crowi.publicDir + '/images/file-not-found.png');
     });
   };
 
@@ -45,8 +64,15 @@ module.exports = function(crowi, app) {
 
     Attachment.getListByPageId(id)
     .then(function(attachments) {
+      var config = crowi.getConfig();
+      var baseUrl = (config.crowi['app:url'] || '');
       return res.json(ApiResponse.success({
-        attachments: attachments
+        attachments: attachments.map(at => {
+          var fileUrl = at.fileUrl;
+          at = at.toObject();
+          at.url = baseUrl + fileUrl;
+          return at;
+        })
       }));
     });
   };
@@ -107,11 +133,18 @@ module.exports = function(crowi, app) {
           // TODO size
           return Attachment.create(id, req.user, filePath, originalName, fileName, fileType, fileSize);
         }).then(function(data) {
-          var imageUrl = fileUploader.generateUrl(data.filePath);
+          var fileUrl = data.fileUrl;
+          var config = crowi.getConfig();
+
+          // isLocalUrl??
+          if (!fileUrl.match(/^https?/)) {
+            fileUrl = (config.crowi['app:url'] || '') + fileUrl;
+          }
+
           var result = {
             page: page.toObject(),
             attachment: data.toObject(),
-            filename: imageUrl,
+            url: fileUrl,
             pageCreated: pageCreated,
           };
 
@@ -139,8 +172,31 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {post} /attachments.remove Remove attachments
+   * @apiName RemoveAttachments
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} attachment_id
+   */
   api.remove = function(req, res){
-    var id = req.params.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 => {
+        return res.status(500).json(ApiResponse.error('Error while deleting file'));
+      });
+    }).catch(err => {
+      debug('Error', err);
+      return res.status(404);
+    });
   };
 
   return actions;

+ 1 - 0
lib/routes/index.js

@@ -105,6 +105,7 @@ module.exports = function(crowi, app) {
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired(crowi, app) , user.api.list);
+  app.get('/_api/pages.list'          , accessTokenParser , loginRequired(crowi, app) , page.api.list);
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app) , page.api.get);

+ 59 - 32
lib/routes/page.js

@@ -188,36 +188,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.search = function(req, res) {
-    // spec: ?q=query&sort=sort_order&author=author_filter
-    var query = req.query.q;
-    var search = require('../util/search')(crowi);
-
-    search.searchPageByKeyword(query)
-    .then(function(pages) {
-      debug('pages', pages);
-
-      if (pages.hits.total <= 0) {
-        return Promise.resolve([]);
-      }
-
-      var ids = pages.hits.hits.map(function(page) {
-        return page._id;
-      });
-
-      return Page.findListByPageIds(ids);
-    }).then(function(pages) {
-
-      res.render('page_list', {
-        path: '/',
-        pages: pages,
-        pager: generatePager({offset: 0, limit: 50})
-      });
-    }).catch(function(err) {
-      debug('search error', err);
-    });
-  };
-
   function renderPage(pageData, req, res) {
     // create page
     if (!pageData) {
@@ -307,6 +277,11 @@ module.exports = function(crowi, app) {
       return renderPage(page, req, res);
     }).catch(function(err) {
 
+      const normalizedPath = Page.normalizePath(path);
+      if (normalizedPath !== path) {
+        return res.redirect(normalizedPath);
+      }
+
       // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
       // これ以前に定義されているはずなので、こうしてしまって問題ない。
       if (!Page.isCreatableName(path)) {
@@ -384,7 +359,7 @@ module.exports = function(crowi, app) {
 
       if (data && !data.isUpdatable(currentRevision)) {
         debug('Conflict occured');
-        req.form.errors.push('すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。');
+        req.form.errors.push('page_edit.notice.conflict');
         throw new Error('Conflict.');
       }
 
@@ -526,6 +501,59 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {get} /pages.list List pages by user
+   * @apiName ListPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} path
+   * @apiParam {String} user
+   */
+  api.list = function(req, res) {
+    var username = req.query.user || null;
+    var path = req.query.path || null;
+    var limit = 50;
+    var offset = parseInt(req.query.offset) || 0;
+
+    var pagerOptions = { offset: offset, limit : limit };
+    var queryOptions = { offset: offset, limit : limit + 1};
+
+    // Accepts only one of these
+    if (username === null && path === null) {
+      return res.json(ApiResponse.error('Parameter user or path is required.'));
+    }
+    if (username !== null && path !== null) {
+      return res.json(ApiResponse.error('Parameter user or path is required.'));
+    }
+
+    var pageFetcher;
+    if (path === null) {
+      pageFetcher = User.findUserByUsername(username)
+      .then(function(user) {
+        if (user === null) {
+          throw new Error('The user not found.');
+        }
+        return Page.findListByCreator(user, queryOptions, req.user);
+      });
+    } else {
+      pageFetcher = Page.findListByStartWith(path, req.user, queryOptions);
+    }
+
+    pageFetcher
+    .then(function(pages) {
+      if (pages.length > limit) {
+        pages.pop();
+      }
+      pagerOptions.length = pages.length;
+
+      var result = {};
+      result.pages = pages;
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
   /**
    * @api {post} /pages.create Create new page
    * @apiName CreatePage
@@ -612,7 +640,6 @@ module.exports = function(crowi, app) {
       debug('error on _api/pages.update', err);
       return res.json(ApiResponse.error(err));
     });
-
   };
 
   /**

+ 1 - 1
lib/views/_form.html

@@ -7,7 +7,7 @@
 <div class="alert alert-danger">
   <ul>
   {% for error in req.form.errors %}
-    <li>{{ error }}</li>
+    <li>{{ t(error) }}</li>
   {% endfor %}
 
   </ul>

+ 20 - 0
lib/views/admin/app.html

@@ -52,6 +52,26 @@
           </div>
         </div>
 
+        <div class="form-group">
+          <div class="col-xs-offset-3 col-xs-6">
+            <input type="checkbox" name="settingForm[app:fileUpload]" value="1"
+              {% if settingForm['app:fileUpload'] %}
+              checked
+              {% endif %}
+              {% if not isUploadable() %}
+              disabled="disabled"
+              {% else %}
+              {% endif %}
+              >
+              <label for="settingForm[app:fileUpload]" class="">画像以外のファイルアップロードを許可</label>
+
+              <p class="help-block">
+                ファイルアップロードの設定を有効にしている場合にのみ、選択可能です。<br>
+                許可をしている場合、画像以外のファイルをページに添付可能になります。
+              </p>
+          </div>
+        </div>
+
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 4 - 0
lib/views/layout/layout.html

@@ -167,4 +167,8 @@
 </body>
 {% endblock %}
 
+<script type="application/json" id="crowi-context-hydrate">
+{{ local_config|json|safe }}
+</script>
+
 </html>

+ 1 - 7
lib/views/modal/rename.html

@@ -20,13 +20,6 @@
                 <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
               </div>
             </div>
-            <div class="checkbox">
-               <label>
-                 <input name="create_redirect" value="1"  type="checkbox"> {{ t('Redirect') }}
-               </label>
-               <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
-               </p>
-            </div>
             {# <div class="checkbox"> #}
             {#    <label> #}
             {#      <input name="moveUnderTrees" value="1" type="checkbox"> 下層ページも全部移動する #}
@@ -37,6 +30,7 @@
         </div>
         <div class="modal-footer">
           <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <input type="hidden" name="create_redirect" value="1">
           <input type="hidden" name="_csrf" value="{{ csrf() }}">
           <input type="hidden" name="path" value="{{ page.path }}">
           <input type="hidden" name="page_id" value="{{ page._id.toString() }}">

+ 14 - 8
lib/views/page.html

@@ -17,7 +17,11 @@
       <h1 class="title flex-item-title" id="revision-path"></h1>
       {% if page %}
       <div class="flex-item-action">
-        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+        <span id="bookmark-button">
+          <p class="bookmark-link">
+            <i class="fa fa-star-o"></i>
+          </p>
+        </span>
       </div>
       <div class="flex-item-action visible-xs visible-sm">
         <button
@@ -37,7 +41,11 @@
     <div class="flex-title-line">
       <h1 class="title flex-item-title"></h1>
       <div class="flex-item-action">
-        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+        <span id="bookmark-button">
+          <p class="bookmark-link">
+            <i class="fa fa-star-o"></i>
+          </a>
+        </span>
       </div>
     </div>
   </header>
@@ -64,6 +72,7 @@
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
+  data-csrftoken="{{ csrf() }}"
   >
 
   {% if not page %}
@@ -90,7 +99,7 @@
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
             <input type="hidden" name="path" value="{{ page.path }}">
             <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
-            <button type="submit" class="btn btn-success btn-sm">
+            <button type="submit" class="btn btn-default btn-sm">
               <i class="fa fa-undo" aria-hidden="true"></i>
               Put Back
             </button>
@@ -236,13 +245,10 @@
 
 
 {% if page %}
-<div class="page-attachments meta">
-  <p>Attachments</p>
-  <ul>
-  </ul>
+<div class="page-attachments meta" id="page-attachment">
 </div>
 
-<p class="meta">
+<p class="page-meta meta">
   Path: <span id="pagePath">{{ page.path }}</span><br>
   {# for BC #}
   {% if page.lastUpdateUser %}

+ 1 - 1
lib/views/page_list.html

@@ -31,7 +31,7 @@
       </h1>
       {% if page %}
       <div class="flex-item-action">
-        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+        <span id="bookmark-button" data-csrftoken="{{ csrf() }}"></span>
       </div>
       <div class="flex-item-action visible-xs visible-sm">
         <button

+ 1 - 1
lib/views/user_page.html

@@ -10,7 +10,7 @@
   <h1 class="title" id="revision-path"></h1>
   <div class="user-page-header">
   {% if page %}
-    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    <span id="bookmark-button" data-csrftoken="{{ csrf() }}"></span>
   {% endif %}
     <div class="pull-left user-page-picture">
       <img src="{{ pageUser|picture }}" class="picture picture-rounded">

+ 110 - 12
local_modules/crowi-fileupload-aws/index.js

@@ -4,9 +4,9 @@ module.exports = function(crowi) {
   'use strict';
 
   var aws = require('aws-sdk')
+    , fs = require('fs')
+    , path = require('path')
     , debug = require('debug')('crowi:lib:fileUploaderAws')
-    , Config = crowi.model('Config')
-    , config = crowi.getConfig()
     , lib = {}
     , getAwsConfig = function() {
         var config = crowi.getConfig();
@@ -18,17 +18,13 @@ module.exports = function(crowi) {
         };
       };
 
-  lib.deleteFile = function(filePath) {
-    return new Promise(function(resolve, reject) {
-      debug('Unsupported file deletion.');
-      resolve('TODO: ...');
-    });
-  };
+  function S3Factory() {
+    const awsConfig = getAwsConfig();
+    const Config = crowi.model('Config');
+    const config = crowi.getConfig();
 
-  lib.uploadFile = function(filePath, contentType, fileStream, options) {
-    var awsConfig = getAwsConfig();
     if (!Config.isUploadable(config)) {
-      return Promise.reject(new Error('AWS is not configured.'));
+      throw new Error('AWS is not configured.');
     }
 
     aws.config.update({
@@ -36,7 +32,37 @@ module.exports = function(crowi) {
       secretAccessKey: awsConfig.secretAccessKey,
       region: awsConfig.region
     });
-    var s3 = 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,
+    };
+
+    return new Promise((resolve, reject) => {
+      s3.deleteObject(params, (err, data) => {
+        if (err) {
+          debug('Failed to delete object from s3', err);
+          return reject(err);
+        }
+
+        // asynclonousely delete cache
+        lib.clearCache(fileId);
+
+        resolve(data);
+      });
+    });
+  };
+
+  lib.uploadFile = function(filePath, contentType, fileStream, options) {
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
 
     var params = {Bucket: awsConfig.bucket};
     params.ContentType = contentType;
@@ -62,6 +88,78 @@ module.exports = function(crowi) {
     return url;
   };
 
+  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);
+      var request = loader.get(fileUrl, function(response) {
+        response.pipe(fileStream, { end: false });
+        response.on('end', () => {
+          fileStream.end();
+          resolve(cacheFile);
+        });
+      });
+    });
+  };
+
+  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
+    });
+  }
+
+  // private
+  lib.createCacheFileName = function(fileId) {
+    return path.join(crowi.cacheDir, `attachment-${fileId}`);
+  };
+
+  // private
+  lib.shouldUpdateCacheFile = function(filePath) {
+    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;
+      }
+    } catch (e) {
+      // no such file or directory
+      debug('Stats error', e);
+      return true;
+    }
+
+    return false;
+  };
+
   return lib;
 };
 

+ 5 - 1
local_modules/crowi-fileupload-local/index.js

@@ -12,7 +12,7 @@ module.exports = function(crowi) {
     , lib = {}
     , basePath = path.join(crowi.publicDir, 'uploads'); // TODO: to configurable
 
-  lib.deleteFile = function(filePath) {
+  lib.deleteFile = function(fileId, filePath) {
     debug('File deletion: ' + filePath);
     return new Promise(function(resolve, reject) {
       fs.unlink(path.join(basePath, filePath), function(err) {
@@ -54,6 +54,10 @@ module.exports = function(crowi) {
     return path.join('/uploads', filePath);
   };
 
+  lib.findDeliveryFile = function (fileId, filePath) {
+    return Promise.resolve(lib.generateUrl(filePath));
+  };
+
   return lib;
 };
 

+ 6 - 0
locales/en-US/translation.json

@@ -141,6 +141,12 @@
       }
   },
 
+  "page_edit": {
+      "notice": {
+          "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
+      }
+  },
+
   "Rename page": "Rename page",
   "New page name": "New page name",
   "Current page name": "Current page name",

+ 6 - 0
locales/ja/translation.json

@@ -141,6 +141,12 @@
       }
   },
 
+  "page_edit": {
+      "notice": {
+          "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
+      }
+  },
+
   "Rename page": "ページを移動する",
   "New page name": "移動先のページ名",
   "Current page name": "現在のページ名",

+ 4 - 3
package.json

@@ -49,7 +49,8 @@
     "aws-sdk": "~2.2.26",
     "axios": "^0.16.1",
     "babel-core": "^6.24.0",
-    "babel-loader": "^6.4.1",
+    "babel-loader": "^7.0.0",
+    "babel-preset-env": "^1.4.0",
     "babel-preset-es2015": "^6.24.0",
     "babel-preset-react": "^6.23.0",
     "basic-auth-connect": "~1.0.0",
@@ -103,11 +104,11 @@
     "nodemailer-ses-transport": "~1.5.0",
     "normalize-path": "^2.1.1",
     "optimize-js-plugin": "0.0.4",
-    "react": "^15.4.2",
+    "react": "^15.5.0",
     "react-bootstrap": "^0.31.0",
     "react-bootstrap-typeahead": "^1.3.0",
     "react-clipboard.js": "^1.0.1",
-    "react-dom": "^15.4.2",
+    "react-dom": "^15.5.0",
     "redis": "^2.7.1",
     "reveal.js": "~3.4.0",
     "rimraf": "^2.6.1",

+ 30 - 0
resource/css/_attachments.scss

@@ -0,0 +1,30 @@
+
+.page-attachments {
+  .attachment-in-use {
+    margin: 0 0 0 4px;
+    padding: 1px 5px;
+  }
+
+  .attachment-filetype {
+    margin: 0 0 0 4px;
+    padding: 1px 5px;
+    font-weight: normal;
+  }
+
+  .attachment-delete {
+    cursor: pointer;
+    margin: 0 0 0 4px;
+  }
+
+}
+
+.attachment-delete-modal {
+
+  .attachment-delete-image {
+    text-align: center;
+
+    img {
+      max-width: 100%;
+    }
+  }
+}

+ 9 - 0
resource/css/_user.scss

@@ -58,3 +58,12 @@
     }
   } // }}}
 }
+
+.user-component {
+  img.picture {
+    margin-right: 4px;
+  }
+  span {
+    margin-right: 4px;
+  }
+}

+ 6 - 2
resource/css/crowi.scss

@@ -13,6 +13,7 @@ $bootstrap-sass-asset-helper: true;
 
 // crowi component
 @import 'admin';
+@import 'attachments';
 @import 'comment';
 @import 'delete';
 @import 'form';
@@ -31,8 +32,7 @@ ul {
 
 
 .meta {
-
-  margin-top: 32px;
+  margin-top: 0;
   padding: 16px;
   color: #666;
   border-top: solid 1px #ccc;
@@ -46,6 +46,10 @@ ul {
   }
 }
 
+.page-meta {
+  margin-bottom: 0;
+}
+
 .help-block {
   font-size: .9em;
 }

+ 19 - 5
resource/js/app.js

@@ -8,9 +8,11 @@ import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
+import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
+import BookmarkButton   from './components/BookmarkButton';
 //import PageComment  from './components/PageComment';
 
 if (!window) {
@@ -20,14 +22,23 @@ if (!window) {
 const mainContent = document.querySelector('#content-main');
 let pageId = null;
 let pagePath;
+let pageContent = null;
 if (mainContent !== null) {
   pageId = mainContent.attributes['data-page-id'].value;
   pagePath = mainContent.attributes['data-path'].value;
+  const rawText = document.getElementById('raw-text-original');
+  if (rawText) {
+    pageContent = rawText.innerHTML;
+  }
 }
 
 // FIXME
-const crowi = new Crowi({me: $('#content-main').data('current-username')}, window);
+const crowi = new Crowi({
+  me: $('#content-main').data('current-username'),
+  csrfToken: $('#content-main').data('csrftoken'),
+}, window);
 window.crowi = crowi;
+crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
 crowi.fetchUsers();
 
 const crowiRenderer = new CrowiRenderer();
@@ -41,12 +52,15 @@ if (isEnabledPlugins) {
 }
 
 const componentMappings = {
-  'search-top': <HeaderSearchBox />,
-  'search-page': <SearchPage />,
-  'page-list-search': <PageListSearch />,
+  'search-top': <HeaderSearchBox crowi={crowi} />,
+  'search-page': <SearchPage crowi={crowi} />,
+  'page-list-search': <PageListSearch crowi={crowi} />,
+  'page-attachment': <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />,
+
   //'revision-history': <PageHistory pageId={pageId} />,
   //'page-comment': <PageComment />,
-  'seen-user-list': <SeenUserList />,
+  'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
+  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
 };
 if (pagePath) {
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} />;

+ 67 - 0
resource/js/components/BookmarkButton.js

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Icon from './Common/Icon'
+
+export default class BookmarkButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      bookmarked: false,
+    };
+
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  componentDidMount() {
+    this.props.crowi.apiGet('/bookmarks.get', {page_id: this.props.pageId})
+    .then(res => {
+      if (res.bookmark) {
+        this.markBookmarked();
+      }
+    });
+  }
+
+  handleClick(event) {
+    event.preventDefault();
+
+    const pageId = this.props.pageId;
+
+    if (!this.state.bookmarked) {
+      this.props.crowi.apiPost('/bookmarks.add', {page_id: pageId})
+      .then(res => {
+        this.markBookmarked();
+      });
+    } else {
+      this.props.crowi.apiPost('/bookmarks.remove', {page_id: pageId})
+      .then(res => {
+        this.markUnBookmarked();
+      });
+    }
+  }
+
+  markBookmarked() {
+    this.setState({bookmarked: true});
+  }
+
+  markUnBookmarked() {
+    this.setState({bookmarked: false});
+  }
+
+  render() {
+    const iconName = this.state.bookmarked ? 'star' : 'star-o';
+
+    return (
+      <a href="#" title="Bookmark" className="bookmark-link" onClick={this.handleClick}>
+        <Icon name={iconName} />
+      </a>
+    );
+  }
+}
+
+BookmarkButton.propTypes = {
+  pageId: PropTypes.string,
+  crowi: PropTypes.object.isRequired,
+};

+ 10 - 3
resource/js/components/Common/Icon.js

@@ -1,22 +1,29 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 export default class Icon extends React.Component {
 
   render() {
     const name = this.props.name || null;
+    const isSpin = this.props.spin ? ' fa-spinner' : '';
 
     if (!name) {
       return '';
     }
 
     return (
-      <i className={"fa fa-" + name} />
+      <i className={`fa fa-${name} ${isSpin}`} />
     );
   }
 }
 
-// TODO: support spin, size and so far
+// TODO: support size and so far
 Icon.propTypes = {
-  name: React.PropTypes.string.isRequired,
+  name: PropTypes.string.isRequired,
+  spin: PropTypes.bool,
+};
+
+Icon.defaltProps = {
+  spin: false,
 };
 

+ 69 - 0
resource/js/components/Common/Modal.js

@@ -0,0 +1,69 @@
+import React from 'react';
+
+export default class Modal extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      modalShown: false,
+    };
+  }
+
+  render() {
+    if (!this.state.modalShown) {
+      return '';
+    }
+
+    return (
+      <div class="modal in" id="renamePage" style="display: block;">
+        <div class="modal-dialog">
+          <div class="modal-content">
+
+          <form role="form" id="renamePageForm" onsubmit="return false;">
+
+            <div class="modal-header">
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+              <h4 class="modal-title">Rename page</h4>
+            </div>
+            <div class="modal-body">
+                <div class="form-group">
+                  <label for="">Current page name</label><br>
+                  <code>/user/sotarok/memo/2017/04/24</code>
+                </div>
+                <div class="form-group">
+                  <label for="newPageName">New page name</label><br>
+                  <div class="input-group">
+                    <span class="input-group-addon">http://localhost:3000</span>
+                    <input type="text" class="form-control" name="new_path" id="newPageName" value="/user/sotarok/memo/2017/04/24">
+                  </div>
+                </div>
+                <div class="checkbox">
+                   <label>
+                     <input name="create_redirect" value="1" type="checkbox"> Redirect
+                   </label>
+                   <p class="help-block"> Redirect to new page if someone accesses <code>/user/sotarok/memo/2017/04/24</code>
+                   </p>
+                </div>
+
+
+
+
+
+
+
+            </div>
+            <div class="modal-footer">
+              <p><small class="pull-left" id="newPageNameCheck"></small></p>
+              <input type="hidden" name="_csrf" value="RCs7uFdR-4nacCnqKfREe8VIlcYLP2J8xzpU">
+              <input type="hidden" name="path" value="/user/sotarok/memo/2017/04/24">
+              <input type="hidden" name="page_id" value="58fd0bd74c844b8f94c2e5b3">
+              <input type="hidden" name="revision_id" value="58fd126385edfb9d8a0c073a">
+              <input type="submit" class="btn btn-primary" value="Rename!">
+            </div>
+
+          </form>
+          </div><!-- /.modal-content -->
+        </div><!-- /.modal-dialog -->
+      </div>
+  );
+}

+ 4 - 3
resource/js/components/Common/UserDate.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import moment from 'moment';
 
@@ -21,9 +22,9 @@ export default class UserDate extends React.Component {
 }
 
 UserDate.propTypes = {
-  dateTime: React.PropTypes.string.isRequired,
-  format: React.PropTypes.string,
-  className: React.PropTypes.string,
+  dateTime: PropTypes.string.isRequired,
+  format: PropTypes.string,
+  className: PropTypes.string,
 };
 
 UserDate.defaultProps = {

+ 1 - 1
resource/js/components/HeaderSearchBox.js

@@ -30,7 +30,7 @@ export default class SearchBox extends React.Component {
 }
 
 SearchBox.propTypes = {
-  //pollInterval: React.PropTypes.number,
+  //pollInterval: PropTypes.number,
 };
 SearchBox.defaultProps = {
   //pollInterval: 1000,

+ 1 - 0
resource/js/components/HeaderSearchBox/SearchForm.js

@@ -8,6 +8,7 @@ import axios from 'axios'
 import UserPicture from '../User/UserPicture';
 import PageListMeta from '../PageList/PageListMeta';
 import PagePath from '../PageList/PagePath';
+import PropTypes from 'prop-types';
 
 // Header.SearchForm
 export default class SearchForm extends React.Component {

+ 4 - 3
resource/js/components/HeaderSearchBox/SearchSuggest.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import ListView from '../PageList/ListView';
 
@@ -46,9 +47,9 @@ export default class SearchSuggest extends React.Component {
 }
 
 SearchSuggest.propTypes = {
-  searchedPages: React.PropTypes.array.isRequired,
-  searchingKeyword: React.PropTypes.string.isRequired,
-  searching: React.PropTypes.bool.isRequired,
+  searchedPages: PropTypes.array.isRequired,
+  searchingKeyword: PropTypes.string.isRequired,
+  searching: PropTypes.bool.isRequired,
 };
 
 SearchSuggest.defaultProps = {

+ 4 - 3
resource/js/components/Page/PageBody.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 export default class PageBody extends React.Component {
 
@@ -31,9 +32,9 @@ export default class PageBody extends React.Component {
 }
 
 PageBody.propTypes = {
-  page: React.PropTypes.object.isRequired,
-  pageBody: React.PropTypes.string,
-  rendererOptions: React.PropTypes.object
+  page: PropTypes.object.isRequired,
+  pageBody: PropTypes.string,
+  rendererOptions: React.PropTypes.object,
 };
 
 PageBody.defaultProps = {

+ 65 - 0
resource/js/components/Page/PagePath.js

@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class PagePath extends React.Component {
+
+  // Original Crowi.linkPath
+  /*
+  Crowi.linkPath = function(revisionPath) {
+    var $revisionPath = revisionPath || '#revision-path';
+    var $title = $($revisionPath);
+    var pathData = $('#content-main').data('path');
+
+    if (!pathData) {
+      return ;
+    }
+
+    var realPath = pathData.trim();
+    if (realPath.substr(-1, 1) == '/') {
+      realPath = realPath.substr(0, realPath.length - 1);
+    }
+
+    var path = '';
+    var pathHtml = '';
+    var splittedPath = realPath.split(/\//);
+    splittedPath.shift();
+    splittedPath.forEach(function(sub) {
+      path += '/';
+      pathHtml += ' <a href="' + path + '">/</a> ';
+      if (sub) {
+        path += sub;
+        pathHtml += '<a href="' + path + '">' + sub + '</a>';
+      }
+    });
+    if (path.substr(-1, 1) != '/') {
+      path += '/';
+      pathHtml += ' <a href="' + path + '" class="last-path">/</a>';
+    }
+    $title.html(pathHtml);
+  };
+  */
+
+  linkPath(path) {
+    return path;
+  }
+
+  render() {
+    const page = this.props.page;
+    const shortPath = this.getShortPath(page.path);
+    const pathPrefix = page.path.replace(new RegExp(shortPath + '(/)?$'), '');
+
+    return (
+      <span className="page-path">
+        {pathPrefix}<strong>{shortPath}</strong>
+      </span>
+    );
+  }
+}
+
+PagePath.propTypes = {
+  page: PropTypes.object.isRequired,
+};
+
+PagePath.defaultProps = {
+  page: {},
+};

+ 114 - 0
resource/js/components/PageAttachment.js

@@ -0,0 +1,114 @@
+import React from 'react';
+
+import Icon from './Common/Icon';
+import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+
+export default class PageAttachment extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      attachments: [],
+      inUse: {},
+      attachmentToDelete: null,
+      deleting: false,
+      deleteError: '',
+    };
+
+    this.onAttachmentDeleteClicked = this.onAttachmentDeleteClicked.bind(this);
+    this.onAttachmentDeleteClickedConfirm = this.onAttachmentDeleteClickedConfirm.bind(this);
+  }
+
+  componentDidMount() {
+    const pageId = this.props.pageId;
+
+    if (!pageId) {
+      return ;
+    }
+
+    this.props.crowi.apiGet('/attachments.list', {page_id: pageId })
+    .then(res => {
+      const attachments = res.attachments;
+      let inUse = {};
+
+      for (const attachment of attachments) {
+        inUse[attachment._id] = this.checkIfFileInUse(attachment);
+      }
+
+      this.setState({
+        attachments: attachments,
+        inUse: inUse,
+      });
+    });
+  }
+
+  checkIfFileInUse(attachment) {
+    if (this.props.pageContent.match(attachment.url)) {
+      return true;
+    }
+    return false;
+  }
+
+  onAttachmentDeleteClicked(attachment) {
+    this.setState({
+      attachmentToDelete: attachment,
+    });
+  }
+
+  onAttachmentDeleteClickedConfirm(attachment) {
+    const attachmentId = attachment._id;
+    this.setState({
+      deleting: true,
+    });
+
+    this.props.crowi.apiPost('/attachments.remove', {attachment_id: attachmentId})
+    .then(res => {
+      this.setState({
+        attachments: this.state.attachments.filter((at) => {
+          return at._id != attachmentId;
+        }),
+        attachmentToDelete: null,
+        deleting: false,
+      });
+    }).catch(err => {
+      this.setState({
+        deleteError: 'Something went wrong.',
+        deleting: false,
+      });
+    });
+  }
+
+  render() {
+    const attachmentToDelete = this.state.attachmentToDelete;
+    let deleteModalClose = () => this.setState({ attachmentToDelete: null });
+    let showModal = attachmentToDelete !== null;
+
+    let deleteInUse = null;
+    if (attachmentToDelete !== null) {
+      deleteInUse = this.state.inUse[attachmentToDelete._id] || false;
+    }
+
+    return (
+      <div>
+        <p>Attachments</p>
+        <PageAttachmentList
+          attachments={this.state.attachments}
+          inUse={this.state.inUse}
+          onAttachmentDeleteClicked={this.onAttachmentDeleteClicked}
+        />
+        <DeleteAttachmentModal
+          show={showModal}
+          animation={false}
+          onHide={deleteModalClose}
+
+          attachmentToDelete={attachmentToDelete}
+          inUse={deleteInUse}
+          deleting={this.state.deleting}
+          deleteError={this.state.deleteError}
+          onAttachmentDeleteClickedConfirm={this.onAttachmentDeleteClickedConfirm}
+        />
+      </div>
+    );
+  }
+}

+ 62 - 0
resource/js/components/PageAttachment/Attachment.js

@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Icon from '../Common/Icon';
+import User from '../User/User';
+
+export default class Attachment extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this._onAttachmentDeleteClicked = this._onAttachmentDeleteClicked.bind(this);
+  }
+
+  iconNameByFormat(format) {
+    if (format.match(/image\/.+/i)) {
+      return 'file-image-o';
+    }
+
+    return 'file-o';
+  }
+
+  _onAttachmentDeleteClicked(event) {
+    this.props.onAttachmentDeleteClicked(this.props.attachment);
+  }
+
+  render() {
+    const attachment = this.props.attachment;
+    const attachmentId = attachment._id
+    const formatIcon = this.iconNameByFormat(attachment.fileFormat);
+
+    let fileInUse = '';
+    if (this.props.inUse) {
+      fileInUse = <span className="attachment-in-use label label-info">In Use</span>;
+    }
+
+    const fileType = <span className="attachment-filetype label label-default">{attachment.fileFormat}</span>;
+
+    return (
+      <li>
+          <User user={attachment.creator} />
+          <Icon name={formatIcon} />
+
+          <a href={attachment.url}> {attachment.originalName}</a>
+
+          {fileType}
+
+          {fileInUse}
+
+          <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}><Icon name="trash-o" /></a>
+      </li>
+    );
+  }
+}
+
+Attachment.propTypes = {
+  attachment: PropTypes.object.isRequired,
+  inUse: PropTypes.bool.isRequired,
+  onAttachmentDeleteClicked: PropTypes.func.isRequired,
+};
+
+Attachment.defaultProps = {
+};

+ 81 - 0
resource/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -0,0 +1,81 @@
+import React from 'react';
+import { Button, Modal } from 'react-bootstrap';
+
+import Icon from '../Common/Icon';
+import User from '../User/User';
+
+export default class DeleteAttachmentModal extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this._onDeleteConfirm = this._onDeleteConfirm.bind(this);
+  }
+
+  _onDeleteConfirm() {
+    this.props.onAttachmentDeleteClickedConfirm(this.props.attachmentToDelete);
+  }
+
+  renderByFileFormat(attachment) {
+    if (attachment.fileFormat.match(/image\/.+/i)) {
+      return (
+        <p className="attachment-delete-image">
+          <span>
+            {attachment.originalName} uploaded by <User user={attachment.creator} username />
+          </span>
+          <img src={attachment.url} />
+        </p>
+      );
+    }
+
+    return (
+        <p className="attachment-delete-file">
+          <Icon name="file-o" />
+        </p>
+    );
+  }
+
+  render() {
+    const attachment = this.props.attachmentToDelete;
+    if (attachment === null) {
+      return null;
+    }
+
+
+    const inUse = this.props.inUse;
+
+    const props = Object.assign({}, this.props);
+    delete props.onAttachmentDeleteClickedConfirm;
+    delete props.attachmentToDelete;
+    delete props.inUse;
+    delete props.deleting;
+    delete props.deleteError;
+
+    let deletingIndicator = '';
+    if (this.props.deleting) {
+      deletingIndicator = <Icon name="spinner" spin />;
+    }
+    if (this.props.deleteError) {
+      deletingIndicator = <p>{this.props.deleteError}</p>;
+    }
+
+    let renderAttachment = this.renderByFileFormat(attachment);
+
+    return (
+      <Modal {...props} className="attachment-delete-modal" bsSize="large" aria-labelledby="contained-modal-title-lg">
+        <Modal.Header closeButton>
+          <Modal.Title id="contained-modal-title-lg">Delete attachment?</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {renderAttachment}
+        </Modal.Body>
+        <Modal.Footer>
+          {deletingIndicator}
+          <Button onClick={this._onDeleteConfirm} bsStyle="danger"
+            disabled={this.props.deleting}
+            >Delete!</Button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+}
+

+ 30 - 0
resource/js/components/PageAttachment/PageAttachmentList.js

@@ -0,0 +1,30 @@
+import React from 'react';
+
+import Attachment from './Attachment';
+
+export default class PageAttachmentList extends React.Component {
+
+  render() {
+    if (this.props.attachments <= 0) {
+      return null;
+    }
+
+    const attachmentList = this.props.attachments.map((attachment, idx) => {
+      return (
+        <Attachment
+          key={"page:attachment:" + attachment._id}
+          attachment={attachment}
+          inUse={this.props.inUse[attachment._id] || false}
+          onAttachmentDeleteClicked={this.props.onAttachmentDeleteClicked}
+         />
+      );
+    });
+
+    return (
+      <ul>
+        {attachmentList}
+      </ul>
+    );
+  }
+}
+

+ 3 - 2
resource/js/components/PageHistory.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import Icon from './Common/Icon';
 import PageRevisionList from './PageHistory/PageRevisionList';
@@ -134,6 +135,6 @@ export default class PageHistory extends React.Component {
 }
 
 PageHistory.propTypes = {
-  pageId: React.PropTypes.string,
-  crowi: React.PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  crowi: PropTypes.object.isRequired,
 };

+ 4 - 3
resource/js/components/PageHistory/PageRevisionList.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import Revision     from './Revision';
 import RevisionDiff from './RevisionDiff';
@@ -47,8 +48,8 @@ export default class PageRevisionList extends React.Component {
 }
 
 PageRevisionList.propTypes = {
-  revisions: React.PropTypes.array,
-  diffOpened: React.PropTypes.object,
-  onDiffOpenClicked: React.PropTypes.func.isRequired,
+  revisions: PropTypes.array,
+  diffOpened: PropTypes.object,
+  onDiffOpenClicked: PropTypes.func.isRequired,
 }
 

+ 3 - 2
resource/js/components/PageHistory/Revision.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import UserDate     from '../Common/UserDate';
 import Icon         from '../Common/Icon';
@@ -53,7 +54,7 @@ export default class Revision extends React.Component {
 }
 
 Revision.propTypes = {
-  revision: React.PropTypes.object,
-  onDiffOpenClicked: React.PropTypes.func.isRequired,
+  revision: PropTypes.object,
+  onDiffOpenClicked: PropTypes.func.isRequired,
 }
 

+ 4 - 3
resource/js/components/PageHistory/RevisionDiff.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import { createPatch } from 'diff';
 import { Diff2Html } from 'diff2html';
@@ -36,7 +37,7 @@ export default class RevisionDiff extends React.Component {
 }
 
 RevisionDiff.propTypes = {
-  currentRevision: React.PropTypes.object.isRequired,
-  previousRevision: React.PropTypes.object.isRequired,
-  revisionDiffOpened: React.PropTypes.bool.isRequired,
+  currentRevision: PropTypes.object.isRequired,
+  previousRevision: PropTypes.object.isRequired,
+  revisionDiffOpened: PropTypes.bool.isRequired,
 }

+ 3 - 2
resource/js/components/PageList/ListView.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import Page from './Page';
 
@@ -6,7 +7,7 @@ export default class ListView extends React.Component {
 
   render() {
     const listView = this.props.pages.map((page) => {
-      return <Page page={page} />;
+      return <Page page={page} key={"page-list:list-view:" + page._id} />;
     });
 
     return (
@@ -20,7 +21,7 @@ export default class ListView extends React.Component {
 }
 
 ListView.propTypes = {
-  pages: React.PropTypes.array.isRequired,
+  pages: PropTypes.array.isRequired,
 };
 
 ListView.defaultProps = {

+ 3 - 2
resource/js/components/PageList/Page.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import UserPicture from '../User/UserPicture';
 import PageListMeta from './PageListMeta';
@@ -27,8 +28,8 @@ export default class Page extends React.Component {
 }
 
 Page.propTypes = {
-  page: React.PropTypes.object.isRequired,
-  linkTo: React.PropTypes.string,
+  page: PropTypes.object.isRequired,
+  linkTo: PropTypes.string,
 };
 
 Page.defaultProps = {

+ 2 - 1
resource/js/components/PageList/PageListMeta.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 export default class PageListMeta extends React.Component {
 
@@ -42,7 +43,7 @@ export default class PageListMeta extends React.Component {
 }
 
 PageListMeta.propTypes = {
-  page: React.PropTypes.object.isRequired,
+  page: PropTypes.object.isRequired,
 };
 
 PageListMeta.defaultProps = {

+ 2 - 1
resource/js/components/PageList/PagePath.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 export default class PagePath extends React.Component {
 
@@ -40,7 +41,7 @@ export default class PagePath extends React.Component {
 }
 
 PagePath.propTypes = {
-  page: React.PropTypes.object.isRequired,
+  page: PropTypes.object.isRequired,
 };
 
 PagePath.defaultProps = {

+ 12 - 22
resource/js/components/PageListSearch.js

@@ -1,7 +1,8 @@
 // This is the root component for #page-list-search
 
 import React from 'react';
-import axios from 'axios'
+import PropTypes from 'prop-types';
+
 import SearchResult from './SearchPage/SearchResult';
 
 export default class PageListSearch extends React.Component {
@@ -119,29 +120,18 @@ export default class PageListSearch extends React.Component {
     this.setState({
       searchingKeyword: keyword,
     });
-    axios.get('/_api/search', {params: {q: keyword, tree: tree}})
-    .then((res) => {
-      if (res.data.ok) {
-
-        this.setState({
-          searchedKeyword: keyword,
-          searchedPages: res.data.data,
-          searchResultMeta: res.data.meta,
-        });
-      } else {
-        this.setState({
-          searchError: res.data,
-        });
-      }
-
 
-      // TODO error
-    })
-    .catch((res) => {
+    this.props.crowi.apiGet('/search', {q: keyword, tree: tree})
+    .then((res) => {
+      this.setState({
+        searchedKeyword: keyword,
+        searchedPages: res.data,
+        searchResultMeta: res.meta,
+      });
+    }).catch(err => {
       this.setState({
-        searchError: res.data,
+        searchError: err,
       });
-      // TODO error
     });
   };
 
@@ -168,7 +158,7 @@ export default class PageListSearch extends React.Component {
 }
 
 PageListSearch.propTypes = {
-  query: React.PropTypes.object,
+  query: PropTypes.object,
 };
 PageListSearch.defaultProps = {
   //pollInterval: 1000,

+ 4 - 5
resource/js/components/SearchPage.js

@@ -1,7 +1,8 @@
 // This is the root component for #search-page
 
 import React from 'react';
-import Crowi from '../util/Crowi';
+import PropTypes from 'prop-types';
+
 import SearchForm from './SearchPage/SearchForm';
 import SearchResult from './SearchPage/SearchResult';
 
@@ -10,8 +11,6 @@ export default class SearchPage extends React.Component {
   constructor(props) {
     super(props);
 
-    this.crowi = window.crowi; // FIXME
-
     this.state = {
       location: location,
       searchingKeyword: this.props.query.q || '',
@@ -72,7 +71,7 @@ export default class SearchPage extends React.Component {
       searchingKeyword: keyword,
     });
 
-    this.crowi.apiGet('/search', {q: keyword})
+    this.props.crowi.apiGet('/search', {q: keyword})
     .then(res => {
       this.changeURL(keyword);
 
@@ -112,7 +111,7 @@ export default class SearchPage extends React.Component {
 }
 
 SearchPage.propTypes = {
-  query: React.PropTypes.object,
+  query: PropTypes.object,
 };
 SearchPage.defaultProps = {
   //pollInterval: 1000,

+ 2 - 1
resource/js/components/SearchPage/SearchForm.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 // Search.SearchForm
 export default class SearchForm extends React.Component {
@@ -53,7 +54,7 @@ export default class SearchForm extends React.Component {
 }
 
 SearchForm.propTypes = {
-  onSearchFormChanged: React.PropTypes.func.isRequired,
+  onSearchFormChanged: PropTypes.func.isRequired,
 };
 SearchForm.defaultProps = {
 };

+ 5 - 4
resource/js/components/SearchPage/SearchResult.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import Page from '../PageList/Page';
 import SearchResultList from './SearchResultList';
@@ -99,10 +100,10 @@ export default class SearchResult extends React.Component {
 }
 
 SearchResult.propTypes = {
-  tree: React.PropTypes.string.isRequired,
-  pages: React.PropTypes.array.isRequired,
-  searchingKeyword: React.PropTypes.string.isRequired,
-  searchResultMeta: React.PropTypes.object.isRequired,
+  tree: PropTypes.string.isRequired,
+  pages: PropTypes.array.isRequired,
+  searchingKeyword: PropTypes.string.isRequired,
+  searchResultMeta: PropTypes.object.isRequired,
 };
 SearchResult.defaultProps = {
   tree: '',

+ 3 - 2
resource/js/components/SearchPage/SearchResultList.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import PageBody from '../Page/PageBody.js';
 
@@ -60,8 +61,8 @@ export default class SearchResultList extends React.Component {
 }
 
 SearchResultList.propTypes = {
-  pages: React.PropTypes.array.isRequired,
-  searchingKeyword: React.PropTypes.string.isRequired,
+  pages: PropTypes.array.isRequired,
+  searchingKeyword: PropTypes.string.isRequired,
 };
 
 SearchResultList.defaultProps = {

+ 1 - 8
resource/js/components/SeenUserList.js

@@ -6,8 +6,6 @@ export default class SeenUserList extends React.Component {
   constructor(props) {
     super(props);
 
-    this.crowi = window.crowi; // FIXME
-
     this.state = {
       seenUsers: [],
     };
@@ -18,12 +16,7 @@ export default class SeenUserList extends React.Component {
 
     if (seenUserIds.length > 0) {
       // FIXME: user data cache
-      this.crowi.apiGet('/users.list', {user_ids: seenUserIds.join(',')})
-      .then(res => {
-        this.setState({seenUsers: res.users});
-      }).catch(err => {
-        // do nothing
-      });
+      this.setState({seenUsers: this.props.crowi.findUserByIds(seenUserIds)});
     }
   }
 

+ 3 - 1
resource/js/components/SeenUserList/UserList.js

@@ -1,4 +1,6 @@
 import React from 'react';
+import PropTypes from 'prop-types';
+
 import UserPicture from '../User/UserPicture';
 
 export default class UserList extends React.Component {
@@ -34,7 +36,7 @@ export default class UserList extends React.Component {
 }
 
 UserList.propTypes = {
-  users: React.PropTypes.array,
+  users: PropTypes.array,
 };
 
 UserList.defaultProps = {

+ 41 - 0
resource/js/components/User/User.js

@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserPicture from './UserPicture';
+
+export default class User extends React.Component {
+
+  render() {
+    const user = this.props.user;
+    const userLink = '/user/' + user.username;
+
+    const username = this.props.username;
+    const name = this.props.name;
+
+    return (
+      <span className="user-component">
+        <a href={userLink}>
+          <UserPicture user={user} />
+
+          {username &&
+              <span className="user-component-username">@{user.username}</span>
+          }
+          {name &&
+              <span className="user-component-name">({user.name})</span>
+          }
+        </a>
+      </span>
+    );
+  }
+}
+
+User.propTypes = {
+  user: PropTypes.object.isRequired,
+  name: PropTypes.bool.isRequired,
+  username: PropTypes.bool.isRequired,
+};
+
+User.defaultProps = {
+  name: false,
+  username: false,
+};

+ 3 - 2
resource/js/components/User/UserPicture.js

@@ -1,5 +1,6 @@
 import React from 'react';
 import md5 from 'md5';
+import PropTypes from 'prop-types';
 
 // TODO UserComponent?
 export default class UserPicture extends React.Component {
@@ -43,8 +44,8 @@ export default class UserPicture extends React.Component {
 }
 
 UserPicture.propTypes = {
-  user: React.PropTypes.object.isRequired,
-  size: React.PropTypes.string,
+  user: PropTypes.object.isRequired,
+  size: PropTypes.string,
 };
 
 UserPicture.defaultProps = {

+ 16 - 3
resource/js/legacy/crowi-form.js

@@ -121,7 +121,8 @@ $(function() {
   var isFormChanged = false;
   $(window).on('beforeunload', function(e) {
     if (isFormChanged) {
-      return '編集中の内容があります。内容を破棄してページを移動しますか?';
+      // TODO i18n
+      return 'You haven\'t finished your comment yet. Do you want to leave without finishing?';
     }
   });
   $('#form-body').on('keyup change', function(e) {
@@ -463,8 +464,17 @@ $(function() {
         _csrf: csrfToken
       },
       progressText: '(Uploading file...)',
-      urlText: "![file]({filename})\n",
-      allowedTypes: '*'
+      jsonFieldName: 'url',
+    };
+
+    // if files upload is set
+    var config = crowi.getConfig();
+    if (config.upload.file) {
+      attachmentOption.allowedTypes = '*';
+    }
+
+    attachmentOption.remoteFilename = function(file) {
+      return file.name;
     };
 
     attachmentOption.onFileReceived = function(file) {
@@ -472,6 +482,9 @@ $(function() {
       if (!file.type.match(/^image\/.+$/)) {
         // modify urlText with 'a' tag
         this.settings.urlText = `<a href="{filename}">${file.name}</a>\n`;
+        this.settings.urlText = `[${file.name}]({filename})\n`;
+      } else {
+        this.settings.urlText = `![${file.name}]({filename})\n`;
       }
     }
 

+ 4 - 72
resource/js/legacy/crowi.js

@@ -586,71 +586,6 @@ $(function() {
       return false;
     });
 
-    // attachment
-    var $pageAttachmentList = $('.page-attachments ul');
-    $.get('/_api/attachments.list', {page_id: pageId}, function(res) {
-      if (!res.ok) {
-        return ;
-      }
-
-      var attachments = res.attachments;
-      if (attachments.length > 0) {
-        $.each(attachments, function(i, file) {
-          $pageAttachmentList.append(
-          '<li><a href="' + file.fileUrl + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
-          );
-        })
-      } else {
-        $('.page-attachments').remove();
-      }
-    });
-
-    // bookmark
-    var $bookmarkButton = $('#bookmark-button');
-    $.get('/_api/bookmarks.get', {page_id: pageId}, function(res) {
-      if (res.ok) {
-        if (res.bookmark) {
-          MarkBookmarked();
-        }
-      }
-    });
-
-    $bookmarkButton.click(function() {
-      var bookmarked = $bookmarkButton.data('bookmarked');
-      var token = $bookmarkButton.data('csrftoken');
-      if (!bookmarked) {
-        $.post('/_api/bookmarks.add', {_csrf: token, page_id: pageId}, function(res) {
-          if (res.ok && res.bookmark) {
-            MarkBookmarked();
-          }
-        });
-      } else {
-        $.post('/_api/bookmarks.remove', {_csrf: token, page_id: pageId}, function(res) {
-          if (res.ok) {
-            MarkUnBookmarked();
-          }
-        });
-      }
-
-      return false;
-    });
-
-    function MarkBookmarked()
-    {
-      $('i', $bookmarkButton)
-        .removeClass('fa-star-o')
-        .addClass('fa-star');
-      $bookmarkButton.data('bookmarked', 1);
-    }
-
-    function MarkUnBookmarked()
-    {
-      $('i', $bookmarkButton)
-        .removeClass('fa-star')
-        .addClass('fa-star-o');
-      $bookmarkButton.data('bookmarked', 0);
-    }
-
     // Like
     var $likeButton = $('.like-button');
     var $likeCount = $('#like-count');
@@ -676,13 +611,10 @@ $(function() {
     var $likerList = $("#liker-list");
     var likers = $likerList.data('likers');
     if (likers && likers.length > 0) {
-      // FIXME: user data cache
-      $.get('/_api/users.list', {user_ids: likers}, function(res) {
-        // ignore unless response has error
-        if (res.ok) {
-          AddToLikers(res.users);
-        }
-      });
+      var users = crowi.findUserByIds(likers.split(','));
+      if (users) {
+        AddToLikers(users);
+      }
     }
 
     function AddToLikers (users) {

+ 30 - 3
resource/js/util/Crowi.js

@@ -12,6 +12,8 @@ import {
 export default class Crowi {
   constructor(context, window) {
     this.context = context;
+    this.config = {};
+    this.csrfToken = context.csrfToken;
 
     this.location = window.location || {};
     this.document = window.document || {};
@@ -50,6 +52,14 @@ export default class Crowi {
     return context;
   }
 
+  setConfig(config) {
+    this.config = config;
+  }
+
+  getConfig() {
+    return this.config;
+  }
+
   recoverData() {
     const keys = [
       'userByName',
@@ -72,7 +82,7 @@ export default class Crowi {
   fetchUsers () {
     const interval = 1000*60*15; // 15min
     const currentTime = new Date();
-    if (!this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
+    if (this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
       return ;
     }
 
@@ -127,6 +137,18 @@ export default class Crowi {
     return null;
   }
 
+  findUserByIds(userIds) {
+    let users = [];
+    for (let userId of userIds) {
+      let user = this.findUserById(userId);
+      if (user) {
+        users.push(user);
+      }
+    }
+
+    return users;
+  }
+
   findUser(username) {
     if (this.userByName && this.userByName[username]) {
       return this.userByName[username];
@@ -136,16 +158,21 @@ export default class Crowi {
   }
 
   apiGet(path, params) {
-    return this.apiRequest('get', path, params);
+    return this.apiRequest('get', path, {params: params});
   }
 
   apiPost(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
     return this.apiRequest('post', path, params);
   }
 
   apiRequest(method, path, params) {
+
     return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, {params})
+      axios[method](`/_api${path}`, params)
       .then(res => {
         if (res.data.ok) {
           resolve(res.data);

+ 16 - 1
resource/js/util/CrowiRenderer.js

@@ -4,7 +4,8 @@ import hljs from 'highlight.js';
 import MarkdownFixer from './PreProcessor/MarkdownFixer';
 import Linker        from './PreProcessor/Linker';
 import ImageExpander from './PreProcessor/ImageExpander';
-import Emoji         from './PreProcessor/Emoji';
+
+import Emoji         from './PostProcessor/Emoji';
 
 import Tsv2Table from './LangProcessor/Tsv2Table';
 import Template from './LangProcessor/Template';
@@ -18,6 +19,8 @@ export default class CrowiRenderer {
       new MarkdownFixer(),
       new Linker(),
       new ImageExpander(),
+    ];
+    this.postProcessors = [
       new Emoji(),
     ];
 
@@ -41,6 +44,17 @@ export default class CrowiRenderer {
     return markdown;
   }
 
+  postProcess(html) {
+    for (let i = 0; i < this.postProcessors.length; i++) {
+      if (!this.postProcessors[i].process) {
+        continue;
+      }
+      html = this.postProcessors[i].process(html);
+    }
+
+    return html;
+  }
+
   codeRenderer(code, lang, escaped) {
     let result = '', hl;
 
@@ -132,6 +146,7 @@ export default class CrowiRenderer {
     let html = '';
 
     html = this.parseMarkdown(markdown, rendererOptions.marked || {});
+    html = this.postProcess(html);
 
     return html;
   }

+ 0 - 0
resource/js/util/PreProcessor/Emoji.js → resource/js/util/PostProcessor/Emoji.js


+ 15 - 0
test/models/page.test.js

@@ -145,6 +145,7 @@ describe('Page', function () {
       expect(Page.isCreatableName('http://demo.crowi.wiki/user/sotarok/hoge')).to.be.false;
       expect(Page.isCreatableName('https://demo.crowi.wiki/user/sotarok/hoge')).to.be.false;
 
+      expect(Page.isCreatableName('/ the / path / with / space')).to.be.false;
 
       var forbidden = ['installer', 'register', 'login', 'logout', 'admin', 'files', 'trash', 'paste', 'comments'];
       for (var i = 0; i < forbidden.length ; i++) {
@@ -264,4 +265,18 @@ describe('Page', function () {
     });
   });
 
+  describe('Normalize path', function () {
+    context('Normalize', function() {
+      it('should start with slash', function(done) {
+        expect(Page.normalizePath('hoge/fuga')).to.equal('/hoge/fuga');
+        done();
+      });
+
+      it('should trim spaces of slash', function(done) {
+        expect(Page.normalizePath('/ hoge / fuga')).to.equal('/hoge/fuga');
+        done();
+      });
+    });
+  });
+
 });

+ 114 - 27
yarn.lock

@@ -328,6 +328,14 @@ babel-generator@^6.24.1:
     source-map "^0.5.0"
     trim-right "^1.0.1"
 
+babel-helper-builder-binary-assignment-operator-visitor@^6.24.1:
+  version "6.24.1"
+  resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664"
+  dependencies:
+    babel-helper-explode-assignable-expression "^6.24.1"
+    babel-runtime "^6.22.0"
+    babel-types "^6.24.1"
+
 babel-helper-builder-react-jsx@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.24.1.tgz#0ad7917e33c8d751e646daca4e77cc19377d2cbc"
@@ -354,6 +362,14 @@ babel-helper-define-map@^6.24.1:
     babel-types "^6.24.1"
     lodash "^4.2.0"
 
+babel-helper-explode-assignable-expression@^6.24.1:
+  version "6.24.1"
+  resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa"
+  dependencies:
+    babel-runtime "^6.22.0"
+    babel-traverse "^6.24.1"
+    babel-types "^6.24.1"
+
 babel-helper-function-name@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9"
@@ -393,6 +409,16 @@ babel-helper-regex@^6.24.1:
     babel-types "^6.24.1"
     lodash "^4.2.0"
 
+babel-helper-remap-async-to-generator@^6.24.1:
+  version "6.24.1"
+  resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b"
+  dependencies:
+    babel-helper-function-name "^6.24.1"
+    babel-runtime "^6.22.0"
+    babel-template "^6.24.1"
+    babel-traverse "^6.24.1"
+    babel-types "^6.24.1"
+
 babel-helper-replace-supers@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a"
@@ -411,14 +437,13 @@ babel-helpers@^6.24.1:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-loader@^6.4.1:
-  version "6.4.1"
-  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-6.4.1.tgz#0b34112d5b0748a8dcdbf51acf6f9bd42d50b8ca"
+babel-loader@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.0.0.tgz#2e43a66bee1fff4470533d0402c8a4532fafbaf7"
   dependencies:
     find-cache-dir "^0.1.1"
-    loader-utils "^0.2.16"
+    loader-utils "^1.0.2"
     mkdirp "^0.5.1"
-    object-assign "^4.0.1"
 
 babel-messages@^6.23.0:
   version "6.23.0"
@@ -432,6 +457,14 @@ babel-plugin-check-es2015-constants@^6.22.0:
   dependencies:
     babel-runtime "^6.22.0"
 
+babel-plugin-syntax-async-functions@^6.8.0:
+  version "6.13.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95"
+
+babel-plugin-syntax-exponentiation-operator@^6.8.0:
+  version "6.13.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de"
+
 babel-plugin-syntax-flow@^6.18.0:
   version "6.18.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz#4c3ab20a2af26aa20cd25995c398c4eb70310c8d"
@@ -440,6 +473,18 @@ babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0:
   version "6.18.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
 
+babel-plugin-syntax-trailing-function-commas@^6.22.0:
+  version "6.22.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3"
+
+babel-plugin-transform-async-to-generator@^6.22.0:
+  version "6.24.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761"
+  dependencies:
+    babel-helper-remap-async-to-generator "^6.24.1"
+    babel-plugin-syntax-async-functions "^6.8.0"
+    babel-runtime "^6.22.0"
+
 babel-plugin-transform-es2015-arrow-functions@^6.22.0:
   version "6.22.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221"
@@ -452,7 +497,7 @@ babel-plugin-transform-es2015-block-scoped-functions@^6.22.0:
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-block-scoping@^6.24.1:
+babel-plugin-transform-es2015-block-scoping@^6.23.0, babel-plugin-transform-es2015-block-scoping@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.24.1.tgz#76c295dc3a4741b1665adfd3167215dcff32a576"
   dependencies:
@@ -462,7 +507,7 @@ babel-plugin-transform-es2015-block-scoping@^6.24.1:
     babel-types "^6.24.1"
     lodash "^4.2.0"
 
-babel-plugin-transform-es2015-classes@^6.24.1:
+babel-plugin-transform-es2015-classes@^6.23.0, babel-plugin-transform-es2015-classes@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db"
   dependencies:
@@ -476,33 +521,33 @@ babel-plugin-transform-es2015-classes@^6.24.1:
     babel-traverse "^6.24.1"
     babel-types "^6.24.1"
 
-babel-plugin-transform-es2015-computed-properties@^6.24.1:
+babel-plugin-transform-es2015-computed-properties@^6.22.0, babel-plugin-transform-es2015-computed-properties@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3"
   dependencies:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-plugin-transform-es2015-destructuring@^6.22.0:
+babel-plugin-transform-es2015-destructuring@^6.22.0, babel-plugin-transform-es2015-destructuring@^6.23.0:
   version "6.23.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d"
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-duplicate-keys@^6.24.1:
+babel-plugin-transform-es2015-duplicate-keys@^6.22.0, babel-plugin-transform-es2015-duplicate-keys@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e"
   dependencies:
     babel-runtime "^6.22.0"
     babel-types "^6.24.1"
 
-babel-plugin-transform-es2015-for-of@^6.22.0:
+babel-plugin-transform-es2015-for-of@^6.22.0, babel-plugin-transform-es2015-for-of@^6.23.0:
   version "6.23.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691"
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-function-name@^6.24.1:
+babel-plugin-transform-es2015-function-name@^6.22.0, babel-plugin-transform-es2015-function-name@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b"
   dependencies:
@@ -516,7 +561,7 @@ babel-plugin-transform-es2015-literals@^6.22.0:
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-modules-amd@^6.24.1:
+babel-plugin-transform-es2015-modules-amd@^6.22.0, babel-plugin-transform-es2015-modules-amd@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154"
   dependencies:
@@ -524,7 +569,7 @@ babel-plugin-transform-es2015-modules-amd@^6.24.1:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
+babel-plugin-transform-es2015-modules-commonjs@^6.23.0, babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.24.1.tgz#d3e310b40ef664a36622200097c6d440298f2bfe"
   dependencies:
@@ -533,7 +578,7 @@ babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
     babel-template "^6.24.1"
     babel-types "^6.24.1"
 
-babel-plugin-transform-es2015-modules-systemjs@^6.24.1:
+babel-plugin-transform-es2015-modules-systemjs@^6.23.0, babel-plugin-transform-es2015-modules-systemjs@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23"
   dependencies:
@@ -541,7 +586,7 @@ babel-plugin-transform-es2015-modules-systemjs@^6.24.1:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-plugin-transform-es2015-modules-umd@^6.24.1:
+babel-plugin-transform-es2015-modules-umd@^6.23.0, babel-plugin-transform-es2015-modules-umd@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468"
   dependencies:
@@ -549,14 +594,14 @@ babel-plugin-transform-es2015-modules-umd@^6.24.1:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-plugin-transform-es2015-object-super@^6.24.1:
+babel-plugin-transform-es2015-object-super@^6.22.0, babel-plugin-transform-es2015-object-super@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d"
   dependencies:
     babel-helper-replace-supers "^6.24.1"
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-parameters@^6.24.1:
+babel-plugin-transform-es2015-parameters@^6.23.0, babel-plugin-transform-es2015-parameters@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b"
   dependencies:
@@ -567,7 +612,7 @@ babel-plugin-transform-es2015-parameters@^6.24.1:
     babel-traverse "^6.24.1"
     babel-types "^6.24.1"
 
-babel-plugin-transform-es2015-shorthand-properties@^6.24.1:
+babel-plugin-transform-es2015-shorthand-properties@^6.22.0, babel-plugin-transform-es2015-shorthand-properties@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0"
   dependencies:
@@ -580,7 +625,7 @@ babel-plugin-transform-es2015-spread@^6.22.0:
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-sticky-regex@^6.24.1:
+babel-plugin-transform-es2015-sticky-regex@^6.22.0, babel-plugin-transform-es2015-sticky-regex@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc"
   dependencies:
@@ -594,13 +639,13 @@ babel-plugin-transform-es2015-template-literals@^6.22.0:
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-typeof-symbol@^6.22.0:
+babel-plugin-transform-es2015-typeof-symbol@^6.22.0, babel-plugin-transform-es2015-typeof-symbol@^6.23.0:
   version "6.23.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372"
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-es2015-unicode-regex@^6.24.1:
+babel-plugin-transform-es2015-unicode-regex@^6.22.0, babel-plugin-transform-es2015-unicode-regex@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9"
   dependencies:
@@ -608,6 +653,14 @@ babel-plugin-transform-es2015-unicode-regex@^6.24.1:
     babel-runtime "^6.22.0"
     regexpu-core "^2.0.0"
 
+babel-plugin-transform-exponentiation-operator@^6.22.0:
+  version "6.24.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e"
+  dependencies:
+    babel-helper-builder-binary-assignment-operator-visitor "^6.24.1"
+    babel-plugin-syntax-exponentiation-operator "^6.8.0"
+    babel-runtime "^6.22.0"
+
 babel-plugin-transform-flow-strip-types@^6.22.0:
   version "6.22.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz#84cb672935d43714fdc32bce84568d87441cf7cf"
@@ -643,7 +696,7 @@ babel-plugin-transform-react-jsx@^6.24.1:
     babel-plugin-syntax-jsx "^6.8.0"
     babel-runtime "^6.22.0"
 
-babel-plugin-transform-regenerator@^6.24.1:
+babel-plugin-transform-regenerator@^6.22.0, babel-plugin-transform-regenerator@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.24.1.tgz#b8da305ad43c3c99b4848e4fe4037b770d23c418"
   dependencies:
@@ -656,6 +709,40 @@ babel-plugin-transform-strict-mode@^6.24.1:
     babel-runtime "^6.22.0"
     babel-types "^6.24.1"
 
+babel-preset-env@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.4.0.tgz#c8e02a3bcc7792f23cded68e0355b9d4c28f0f7a"
+  dependencies:
+    babel-plugin-check-es2015-constants "^6.22.0"
+    babel-plugin-syntax-trailing-function-commas "^6.22.0"
+    babel-plugin-transform-async-to-generator "^6.22.0"
+    babel-plugin-transform-es2015-arrow-functions "^6.22.0"
+    babel-plugin-transform-es2015-block-scoped-functions "^6.22.0"
+    babel-plugin-transform-es2015-block-scoping "^6.23.0"
+    babel-plugin-transform-es2015-classes "^6.23.0"
+    babel-plugin-transform-es2015-computed-properties "^6.22.0"
+    babel-plugin-transform-es2015-destructuring "^6.23.0"
+    babel-plugin-transform-es2015-duplicate-keys "^6.22.0"
+    babel-plugin-transform-es2015-for-of "^6.23.0"
+    babel-plugin-transform-es2015-function-name "^6.22.0"
+    babel-plugin-transform-es2015-literals "^6.22.0"
+    babel-plugin-transform-es2015-modules-amd "^6.22.0"
+    babel-plugin-transform-es2015-modules-commonjs "^6.23.0"
+    babel-plugin-transform-es2015-modules-systemjs "^6.23.0"
+    babel-plugin-transform-es2015-modules-umd "^6.23.0"
+    babel-plugin-transform-es2015-object-super "^6.22.0"
+    babel-plugin-transform-es2015-parameters "^6.23.0"
+    babel-plugin-transform-es2015-shorthand-properties "^6.22.0"
+    babel-plugin-transform-es2015-spread "^6.22.0"
+    babel-plugin-transform-es2015-sticky-regex "^6.22.0"
+    babel-plugin-transform-es2015-template-literals "^6.22.0"
+    babel-plugin-transform-es2015-typeof-symbol "^6.23.0"
+    babel-plugin-transform-es2015-unicode-regex "^6.22.0"
+    babel-plugin-transform-exponentiation-operator "^6.22.0"
+    babel-plugin-transform-regenerator "^6.22.0"
+    browserslist "^1.4.0"
+    invariant "^2.2.2"
+
 babel-preset-es2015@^6.24.0:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz#d44050d6bc2c9feea702aaf38d727a0210538939"
@@ -982,7 +1069,7 @@ browserify-zlib@^0.1.4:
   dependencies:
     pako "~0.2.0"
 
-browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6:
+browserslist@^1.3.6, browserslist@^1.4.0, browserslist@^1.5.2, browserslist@^1.7.6:
   version "1.7.7"
   resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9"
   dependencies:
@@ -2819,7 +2906,7 @@ interpret@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.2.tgz#f4f623f0bb7122f15f5717c8e254b8161b5c5b2d"
 
-invariant@^2.1.0, invariant@^2.2.0, invariant@^2.2.1:
+invariant@^2.1.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360"
   dependencies:
@@ -4700,7 +4787,7 @@ react-clipboard.js@^1.0.1:
   dependencies:
     clipboard "^1.4.0"
 
-react-dom@^15.4.2:
+react-dom@^15.5.0:
   version "15.5.4"
   resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.5.4.tgz#ba0c28786fd52ed7e4f2135fe0288d462aef93da"
   dependencies:
@@ -4751,7 +4838,7 @@ react-prop-types@^0.4.0:
   dependencies:
     warning "^3.0.0"
 
-react@^15.4.2:
+react@^15.5.0:
   version "15.5.4"
   resolved "https://registry.yarnpkg.com/react/-/react-15.5.4.tgz#fa83eb01506ab237cdc1c8c3b1cea8de012bf047"
   dependencies: