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

Merge branch 'master' into feature-modify-scroll-top

Sotaro KARASAWA 9 лет назад
Родитель
Сommit
a15fd04132

+ 3 - 17
lib/crowi/express-init.js

@@ -1,13 +1,12 @@
 'use strict';
 'use strict';
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
-  var express        = require('express')
+  var debug = require('debug')('crowi:crowi:express-init')
+    , express        = require('express')
     , bodyParser     = require('body-parser')
     , bodyParser     = require('body-parser')
     , multer         = require('multer')
     , multer         = require('multer')
-    , morgan         = require('morgan')
     , cookieParser   = require('cookie-parser')
     , cookieParser   = require('cookie-parser')
     , methodOverride = require('method-override')
     , methodOverride = require('method-override')
-    , errorHandler   = require('errorhandler')
     , session        = require('express-session')
     , session        = require('express-session')
     , basicAuth      = require('basic-auth-connect')
     , basicAuth      = require('basic-auth-connect')
     , flash          = require('connect-flash')
     , flash          = require('connect-flash')
@@ -31,6 +30,7 @@ module.exports = function(crowi, app) {
     app.set('tzoffset', tzoffset);
     app.set('tzoffset', tzoffset);
 
 
     req.config = config;
     req.config = config;
+    req.csrfToken = null;
 
 
     config.crowi['app:url'] = baseUrl = (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + '://' + req.get('host');
     config.crowi['app:url'] = baseUrl = (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + '://' + req.get('host');
 
 
@@ -98,18 +98,4 @@ module.exports = function(crowi, app) {
 
 
   app.use(middleware.loginChecker(crowi, app));
   app.use(middleware.loginChecker(crowi, app));
 
 
-  if (env == 'development') {
-    swig.setDefaults({ cache: false });
-    app.use(errorHandler({ dumpExceptions: true, showStack: true }));
-    app.use(morgan('dev'));
-  }
-
-  if (env == 'production') {
-    var oneYear = 31557600000;
-    app.use(morgan('combined'));
-    app.use(function (err, req, res, next) {
-      res.status(500);
-      res.render('500', { error: err });
-    });
-  }
 };
 };

+ 30 - 0
lib/crowi/index.js

@@ -31,6 +31,7 @@ function Crowi (rootdir, env)
   this.searcher = null;
   this.searcher = null;
   this.mailer = {};
   this.mailer = {};
 
 
+  this.tokens = null;
 
 
   this.models = {};
   this.models = {};
 
 
@@ -81,6 +82,8 @@ Crowi.prototype.init = function() {
     return self.setupMailer();
     return self.setupMailer();
   }).then(function() {
   }).then(function() {
     return self.setupSlack();
     return self.setupSlack();
+  }).then(function() {
+    return self.setupCsrf();
   }).then(function() {
   }).then(function() {
     return self.buildServer();
     return self.buildServer();
   });
   });
@@ -242,7 +245,16 @@ Crowi.prototype.setupSlack = function() {
   });
   });
 };
 };
 
 
+Crowi.prototype.setupCsrf = function() {
+  var Tokens = require('csrf');
+  var tokens = this.tokens = new Tokens();
 
 
+  return Promise.resolve();
+};
+
+Crowi.prototype.getTokens = function() {
+  return this.tokens;
+};
 
 
 Crowi.prototype.start = function() {
 Crowi.prototype.start = function() {
   var self = this
   var self = this
@@ -267,12 +279,30 @@ Crowi.prototype.start = function() {
 
 
 Crowi.prototype.buildServer = function() {
 Crowi.prototype.buildServer = function() {
   var express  = require('express')
   var express  = require('express')
+    , errorHandler   = require('errorhandler')
+    , morgan         = require('morgan')
     , app = express()
     , app = express()
+    , env = this.node_env
     ;
     ;
 
 
   require('./express-init')(this, app);
   require('./express-init')(this, app);
   require('../routes')(this, app);
   require('../routes')(this, app);
 
 
+  if (env == 'development') {
+    //swig.setDefaults({ cache: false });
+    app.use(errorHandler({ dumpExceptions: true, showStack: true }));
+    app.use(morgan('dev'));
+  }
+
+  if (env == 'production') {
+    var oneYear = 31557600000;
+    app.use(morgan('combined'));
+    app.use(function (err, req, res, next) {
+      res.status(500);
+      res.render('500', { error: err });
+    });
+  }
+
   return new Promise.resolve(app);
   return new Promise.resolve(app);
 };
 };
 
 

+ 16 - 0
lib/models/attachment.js

@@ -88,5 +88,21 @@ module.exports = function(crowi) {
     return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
     return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
   };
   };
 
 
+  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 mongoose.model('Attachment', attachmentSchema);
   return mongoose.model('Attachment', attachmentSchema);
 };
 };

+ 16 - 1
lib/models/bookmark.js

@@ -116,7 +116,22 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  bookmarkSchema.statics.remove = function(page, user) {
+  bookmarkSchema.statics.removeBookmarksByPageId = function(pageId) {
+    var Bookmark = this;
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.remove({page: pageId}, function(err, data) {
+        if (err) {
+          debug('Bookmark.remove failed (removeBookmarkByPage)', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  bookmarkSchema.statics.removeBookmark = function(page, user) {
     var Bookmark = this;
     var Bookmark = this;
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {

+ 14 - 0
lib/models/comment.js

@@ -100,6 +100,20 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
+  commentSchema.statics.removeCommentsByPageId = function(pageId) {
+    var Comment = this;
+
+    return new Promise(function(resolve, reject) {
+      Comment.remove({page: pageId}, function(err, done) {
+        if (err) {
+          return reject(err);
+        }
+
+        resolve(done);
+      });;
+    });
+  };
+
   /**
   /**
    * post save hook
    * post save hook
    */
    */

+ 175 - 9
lib/models/page.js

@@ -8,6 +8,11 @@ module.exports = function(crowi) {
     , GRANT_OWNER = 4
     , GRANT_OWNER = 4
     , PAGE_GRANT_ERROR = 1
     , PAGE_GRANT_ERROR = 1
 
 
+    , STATUS_WIP        = 'wip'
+    , STATUS_PUBLISHED  = 'published'
+    , STATUS_DELETED    = 'deleted'
+    , STATUS_DEPRECATED = 'deprecated'
+
     , pageEvent = crowi.event('page')
     , pageEvent = crowi.event('page')
 
 
     , pageSchema;
     , pageSchema;
@@ -21,12 +26,16 @@ module.exports = function(crowi) {
   }
   }
 
 
   pageSchema = new mongoose.Schema({
   pageSchema = new mongoose.Schema({
-    path: { type: String, required: true, index: true },
+    path: { type: String, required: true, index: true, unique: true },
     revision: { type: ObjectId, ref: 'Revision' },
     revision: { type: ObjectId, ref: 'Revision' },
     redirectTo: { type: String, index: true },
     redirectTo: { type: String, index: true },
+    status: { type: String, default: STATUS_PUBLISHED, index: true },
     grant: { type: Number, default: GRANT_PUBLIC, index: true },
     grant: { type: Number, default: GRANT_PUBLIC, index: true },
     grantedUsers: [{ type: ObjectId, ref: 'User' }],
     grantedUsers: [{ type: ObjectId, ref: 'User' }],
     creator: { type: ObjectId, ref: 'User', index: true },
     creator: { type: ObjectId, ref: 'User', index: true },
+    // lastUpdateUser: this schema is from 1.5.x (by deletion feature), and null is default.
+    // the last update user on the screen is by revesion.author for B.C.
+    lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
     commentCount: { type: Number, default: 0 },
     commentCount: { type: Number, default: 0 },
@@ -54,6 +63,23 @@ module.exports = function(crowi) {
   pageEvent.on('create', pageEvent.onCreate);
   pageEvent.on('create', pageEvent.onCreate);
   pageEvent.on('update', pageEvent.onUpdate);
   pageEvent.on('update', pageEvent.onUpdate);
 
 
+  pageSchema.methods.isWIP = function() {
+    return this.status === STATUS_WIP;
+  };
+
+  pageSchema.methods.isPublished = function() {
+    // null: this is for B.C.
+    return this.status === null || this.status === STATUS_PUBLISHED;
+  };
+
+  pageSchema.methods.isDeleted = function() {
+    return this.status === STATUS_DELETED;
+  };
+
+  pageSchema.methods.isDeprecated = function() {
+    return this.status === STATUS_DEPRECATED;
+  };
+
   pageSchema.methods.isPublic = function() {
   pageSchema.methods.isPublic = function() {
     if (!this.grant || this.grant == GRANT_PUBLIC) {
     if (!this.grant || this.grant == GRANT_PUBLIC) {
       return true;
       return true;
@@ -232,6 +258,7 @@ module.exports = function(crowi) {
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       pageData.populate([
       pageData.populate([
+        {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS},
         {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
         {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
         {path: 'revision', model: 'Revision'},
         {path: 'revision', model: 'Revision'},
         //{path: 'liker', options: { limit: 11 }},
         //{path: 'liker', options: { limit: 11 }},
@@ -288,10 +315,10 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.hasPortalPage = function (path, user) {
+  pageSchema.statics.hasPortalPage = function (path, user, revisionId) {
     var self = this;
     var self = this;
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
-      self.findPage(path, user)
+      self.findPage(path, user, revisionId)
       .then(function(page) {
       .then(function(page) {
         resolve(page);
         resolve(page);
       }).catch(function(err) {
       }).catch(function(err) {
@@ -322,17 +349,43 @@ module.exports = function(crowi) {
     return '/user/' + user.username;
     return '/user/' + user.username;
   };
   };
 
 
+  pageSchema.statics.getDeletedPageName = function(path) {
+    if (path.match('\/')) {
+      path = path.substr(1);
+    }
+    return '/trash/' + path;
+  };
+
+  pageSchema.statics.getRevertDeletedPageName = function(path) {
+    return path.replace('\/trash', '');
+  };
+
+  pageSchema.statics.isDeletableName = function(path) {
+    var notDeletable = [
+      /^\/user\/[^\/]+$/, // user page
+    ];
+
+    for (var i = 0; i < notDeletable.length; i++) {
+      var pattern = notDeletable[i];
+      if (path.match(pattern)) {
+        return false;
+      }
+    }
+
+    return true;
+  };
+
   pageSchema.statics.isCreatableName = function(name) {
   pageSchema.statics.isCreatableName = function(name) {
     var forbiddenPages = [
     var forbiddenPages = [
       /\^|\$|\*|\+|\#/,
       /\^|\$|\*|\+|\#/,
-      /^\/_api\/.*/,
+      /^\/_.*/, // /_api/* and so on
       /^\/\-\/.*/,
       /^\/\-\/.*/,
       /^\/_r\/.*/,
       /^\/_r\/.*/,
       /^\/user\/[^\/]+\/(bookmarks|comments|activities|pages|recent-create|recent-edit)/, // reserved
       /^\/user\/[^\/]+\/(bookmarks|comments|activities|pages|recent-create|recent-edit)/, // reserved
-      /^http:\/\/.+$/, // avoid miss in renaming
+      /^https?:\/\/.+$/, // avoid miss in renaming
       /.+\/edit$/,
       /.+\/edit$/,
       /.+\.md$/,
       /.+\.md$/,
-      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments).+/,
+      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments)(\/.*|$)/,
     ];
     ];
 
 
     var isCreatable = true;
     var isCreatable = true;
@@ -533,6 +586,7 @@ module.exports = function(crowi) {
     var Page = this;
     var Page = this;
     var User = crowi.model('User');
     var User = crowi.model('User');
     var pathCondition = [];
     var pathCondition = [];
+    var includeDeletedPage = option.includeDeletedPage || false
 
 
     if (!option) {
     if (!option) {
       option = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
       option = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
@@ -573,6 +627,15 @@ module.exports = function(crowi) {
         .skip(opt.offset)
         .skip(opt.offset)
         .limit(opt.limit);
         .limit(opt.limit);
 
 
+      if (!includeDeletedPage) {
+        q.and({
+          $or: [
+            {status: null},
+            {status: STATUS_PUBLISHED},
+          ],
+        });
+      }
+
       q.exec()
       q.exec()
       .then(function(pages) {
       .then(function(pages) {
         Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS})
         Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS})
@@ -652,6 +715,7 @@ module.exports = function(crowi) {
 
 
         debug('Successfully saved new revision', newRevision);
         debug('Successfully saved new revision', newRevision);
         pageData.revision = newRevision;
         pageData.revision = newRevision;
+        pageData.lastUpdateUser = user;
         pageData.updatedAt = Date.now();
         pageData.updatedAt = Date.now();
         pageData.save(function(err, data) {
         pageData.save(function(err, data) {
           if (err) {
           if (err) {
@@ -690,10 +754,12 @@ module.exports = function(crowi) {
           var newPage = new Page();
           var newPage = new Page();
           newPage.path = path;
           newPage.path = path;
           newPage.creator = user;
           newPage.creator = user;
+          newPage.lastUpdateUser = user;
           newPage.createdAt = Date.now();
           newPage.createdAt = Date.now();
           newPage.updatedAt = Date.now();
           newPage.updatedAt = Date.now();
           newPage.redirectTo = redirectTo;
           newPage.redirectTo = redirectTo;
           newPage.grant = grant;
           newPage.grant = grant;
+          newPage.status = STATUS_PUBLISHED;
           newPage.grantedUsers = [];
           newPage.grantedUsers = [];
           newPage.grantedUsers.push(user);
           newPage.grantedUsers.push(user);
 
 
@@ -743,6 +809,108 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
+  pageSchema.statics.deletePage = function(pageData, user, options) {
+    var Page = this
+      , newPath = Page.getDeletedPageName(pageData.path)
+      ;
+    if (Page.isDeletableName(pageData.path)) {
+      return new Promise(function(resolve, reject) {
+        Page.updatePageProperty(pageData, {status: STATUS_DELETED, lastUpdateUser: user})
+        .then(function(data) {
+          pageData.status = STATUS_DELETED;
+
+          // ページ名が /trash/ 以下に存在する場合、おかしなことになる
+          // が、 /trash 以下にページが有るのは、個別に作っていたケースのみ。
+          // 一応しばらく前から uncreatable pages になっているのでこれでいいことにする
+          debug('Deleted the page, and rename it', pageData.path, newPath);
+          return Page.rename(pageData, newPath, user, {createRedirectPage: true})
+        }).then(function(pageData) {
+          resolve(pageData);
+        }).catch(reject);
+      });
+    } else {
+      return Promise.reject('Page is not deletable.');
+    }
+  };
+
+  pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
+    var Page = this
+      , newPath = Page.getRevertDeletedPageName(pageData.path)
+      ;
+
+    // 削除時、元ページの path には必ず redirectTo 付きで、ページが作成される。
+    // そのため、そいつは削除してOK
+    // が、redirectTo ではないページが存在している場合それは何かがおかしい。(データ補正が必要)
+    return new Promise(function(resolve, reject) {
+      Page.findPageByPath(newPath)
+      .then(function(originPageData) {
+        if (originPageData.redirectTo !== pageData.path) {
+          throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
+        }
+
+        return Page.completelyDeletePage(originPageData);
+      }).then(function(done) {
+        return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user})
+      }).then(function(done) {
+        pageData.status = STATUS_PUBLISHED;
+
+        debug('Revert deleted the page, and rename again it', pageData, newPath);
+        return Page.rename(pageData, newPath, user, {})
+      }).then(function(done) {
+        pageData.path = newPath;
+        resolve(pageData);
+      }).catch(reject);
+    });
+  };
+
+  /**
+   * This is danger.
+   */
+  pageSchema.statics.completelyDeletePage = function(pageData, user, options) {
+    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+    var Bookmark = crowi.model('Bookmark')
+      , Attachment = crowi.model('Attachment')
+      , Comment = crowi.model('Comment')
+      , Revision = crowi.model('Revision')
+      , Page = this
+      , pageId = pageData._id
+      ;
+
+    debug('Completely delete', pageData.path);
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.removeBookmarksByPageId(pageId)
+      .then(function(done) {
+      }).then(function(done) {
+        return Attachment.removeAttachmentsByPageId(pageId);
+      }).then(function(done) {
+        return Comment.removeCommentsByPageId(pageId);
+      }).then(function(done) {
+        return Revision.removeRevisionsByPath(pageData.path);
+      }).then(function(done) {
+        return Page.removePageById(pageId);
+      }).then(function(done) {
+        pageEvent.emit('delete', pageData, user); // update as renamed page
+        resolve();
+      }).catch(reject);
+    });
+  };
+
+  pageSchema.statics.removePageById = function(pageId) {
+    var Page = this;
+
+    return new Promise(function(resolve, reject) {
+      Page.remove({_id: pageId}, function(err, done) {
+        debug('Remove phisiaclly, the page', pageId, err, done);
+        if (err) {
+          return reject(err);
+        }
+
+        resolve(done);
+      });
+    });
+  };
+
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
     var Page = this
     var Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
@@ -752,13 +920,11 @@ module.exports = function(crowi) {
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       // pageData の path を変更
       // pageData の path を変更
-      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath})
+      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})
       .then(function(data) {
       .then(function(data) {
-        debug('Before ', pageData);
         // reivisions の path を変更
         // reivisions の path を変更
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {})
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {})
       }).then(function(data) {
       }).then(function(data) {
-        debug('After ', pageData);
         pageData.path = newPagePath;
         pageData.path = newPagePath;
 
 
         if (createRedirectPage) {
         if (createRedirectPage) {

+ 20 - 4
lib/models/revision.js

@@ -38,7 +38,8 @@ module.exports = function(crowi) {
   };
   };
 
 
   revisionSchema.statics.findRevisions = function(ids) {
   revisionSchema.statics.findRevisions = function(ids) {
-    var Revision = this;
+    var Revision = this,
+        User = crowi.model('User');
 
 
     if (!Array.isArray(ids)) {
     if (!Array.isArray(ids)) {
       return Promise.reject('The argument was not Array.');
       return Promise.reject('The argument was not Array.');
@@ -48,7 +49,7 @@ module.exports = function(crowi) {
       Revision
       Revision
         .find({ _id: { $in: ids }})
         .find({ _id: { $in: ids }})
         .sort({createdAt: -1})
         .sort({createdAt: -1})
-        .populate('author')
+        .populate('author', User.USER_PUBLIC_FIELDS)
         .exec(function(err, revisions) {
         .exec(function(err, revisions) {
           if (err) {
           if (err) {
             return reject(err);
             return reject(err);
@@ -60,12 +61,13 @@ module.exports = function(crowi) {
   };
   };
 
 
   revisionSchema.statics.findRevisionList = function(path, options) {
   revisionSchema.statics.findRevisionList = function(path, options) {
-    var Revision = this;
+    var Revision = this,
+        User = crowi.model('User');
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Revision.find({path: path})
       Revision.find({path: path})
         .sort({createdAt: -1})
         .sort({createdAt: -1})
-        .populate('author')
+        .populate('author', User.USER_PUBLIC_FIELDS)
         .exec(function(err, data) {
         .exec(function(err, data) {
           if (err) {
           if (err) {
             return reject(err);
             return reject(err);
@@ -112,6 +114,20 @@ module.exports = function(crowi) {
     return newRevision;
     return newRevision;
   };
   };
 
 
+  revisionSchema.statics.removeRevisionsByPath = function(path) {
+    var Revision = this;
+
+    return new Promise(function(resolve, reject) {
+      Revision.remove({path: path}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
   revisionSchema.statics.updatePath = function(pathName) {
   revisionSchema.statics.updatePath = function(pathName) {
   };
   };
 
 

+ 1 - 1
lib/routes/bookmark.js

@@ -75,7 +75,7 @@ module.exports = function(crowi, app) {
   actions.api.remove = function(req, res){
   actions.api.remove = function(req, res){
     var pageId = req.body.page_id;
     var pageId = req.body.page_id;
 
 
-    Bookmark.remove(pageId, req.user)
+    Bookmark.removeBookmark(pageId, req.user)
     .then(function(data) {
     .then(function(data) {
       debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
       debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());

+ 32 - 27
lib/routes/index.js

@@ -15,20 +15,21 @@ module.exports = function(crowi, app) {
     , search    = require('./search')(crowi, app)
     , search    = require('./search')(crowi, app)
     , loginRequired = middleware.loginRequired
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser
     , accessTokenParser = middleware.accessTokenParser
+    , csrf      = middleware.csrfVerify(crowi, app)
     ;
     ;
 
 
   app.get('/'                        , loginRequired(crowi, app) , page.pageListShow);
   app.get('/'                        , loginRequired(crowi, app) , page.pageListShow);
 
 
   app.get('/installer'               , middleware.applicationNotInstalled() , installer.index);
   app.get('/installer'               , middleware.applicationNotInstalled() , installer.index);
-  app.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , installer.createAdmin);
+  app.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , csrf, installer.createAdmin);
   //app.post('/installer/user'         , middleware.applicationNotInstalled() , installer.createFirstUser);
   //app.post('/installer/user'         , middleware.applicationNotInstalled() , installer.createFirstUser);
 
 
   app.get('/login/error/:reason'     , login.error);
   app.get('/login/error/:reason'     , login.error);
   app.get('/login'                   , middleware.applicationInstalled()    , login.login);
   app.get('/login'                   , middleware.applicationInstalled()    , login.login);
   app.get('/login/invited'           , login.invited);
   app.get('/login/invited'           , login.invited);
-  app.post('/login/activateInvited'  , form.invited                         , login.invited);
-  app.post('/login'                  , form.login                           , login.login);
-  app.post('/register'               , form.register                        , login.register);
+  app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
+  app.post('/login'                  , form.login                           , csrf, login.login);
+  app.post('/register'               , form.register                        , csrf, login.register);
   app.get('/register'                , middleware.applicationInstalled()    , login.register);
   app.get('/register'                , middleware.applicationInstalled()    , login.register);
   app.post('/register/google'        , login.registerGoogle);
   app.post('/register/google'        , login.registerGoogle);
   app.get('/google/callback'         , login.googleCallback);
   app.get('/google/callback'         , login.googleCallback);
@@ -38,32 +39,32 @@ module.exports = function(crowi, app) {
 
 
   app.get('/admin'                      , loginRequired(crowi, app) , middleware.adminRequired() , admin.index);
   app.get('/admin'                      , loginRequired(crowi, app) , middleware.adminRequired() , admin.index);
   app.get('/admin/app'                  , loginRequired(crowi, app) , middleware.adminRequired() , admin.app.index);
   app.get('/admin/app'                  , loginRequired(crowi, app) , middleware.adminRequired() , admin.app.index);
-  app.post('/_api/admin/settings/app'   , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.app, admin.api.appSetting);
+  app.post('/_api/admin/settings/app'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.app, admin.api.appSetting);
   app.post('/_api/admin/settings/sec'   , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.sec, admin.api.appSetting);
   app.post('/_api/admin/settings/sec'   , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.sec, admin.api.appSetting);
-  app.post('/_api/admin/settings/mail'  , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.mail, admin.api.appSetting);
-  app.post('/_api/admin/settings/aws'   , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/google', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.google, admin.api.appSetting);
-  app.post('/_api/admin/settings/fb'    , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.fb , admin.api.appSetting);
+  app.post('/_api/admin/settings/mail'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.mail, admin.api.appSetting);
+  app.post('/_api/admin/settings/aws'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.aws, admin.api.appSetting);
+  app.post('/_api/admin/settings/google', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.google, admin.api.appSetting);
+  app.post('/_api/admin/settings/fb'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.fb , admin.api.appSetting);
 
 
   // search admin
   // search admin
   app.get('/admin/search'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.index);
   app.get('/admin/search'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.index);
-  app.post('/admin/search/build'       , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.buildIndex);
+  app.post('/admin/search/build'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.search.buildIndex);
 
 
   // notification admin
   // notification admin
   app.get('/admin/notification'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.index);
   app.get('/admin/notification'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.index);
-  app.post('/admin/notification/slackSetting', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.slackSetting, admin.notification.slackSetting);
+  app.post('/admin/notification/slackSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.slackSetting, admin.notification.slackSetting);
   app.get('/admin/notification/slackAuth'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.slackAuth);
   app.get('/admin/notification/slackAuth'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.slackAuth);
-  app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.notificationAdd);
-  app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.notificationRemove);
+  app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationAdd);
+  app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationRemove);
 
 
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
-  app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , admin.user.invite);
-  app.post('/admin/user/:id/makeAdmin'  , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.makeAdmin);
+  app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.invite);
+  app.post('/admin/user/:id/makeAdmin'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.makeAdmin);
   app.post('/admin/user/:id/removeFromAdmin', loginRequired(crowi, app) , middleware.adminRequired() , admin.user.removeFromAdmin);
   app.post('/admin/user/:id/removeFromAdmin', loginRequired(crowi, app) , middleware.adminRequired() , admin.user.removeFromAdmin);
-  app.post('/admin/user/:id/activate'   , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.activate);
-  app.post('/admin/user/:id/suspend'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.suspend);
-  app.post('/admin/user/:id/remove'     , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.remove);
-  app.post('/admin/user/:id/removeCompletely' , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.removeCompletely);
+  app.post('/admin/user/:id/activate'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.activate);
+  app.post('/admin/user/:id/suspend'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.suspend);
+  app.post('/admin/user/:id/remove'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.remove);
+  app.post('/admin/user/:id/removeCompletely' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.removeCompletely);
 
 
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
@@ -97,14 +98,16 @@ module.exports = function(crowi, app) {
   app.get('/_api/pages.get'           , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.get);
   app.get('/_api/pages.get'           , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.get);
   app.get('/_api/pages.updatePost'    , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.getUpdatePost);
   app.get('/_api/pages.updatePost'    , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.getUpdatePost);
   app.post('/_api/pages.seen'         , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.seen);
   app.post('/_api/pages.seen'         , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.seen);
-  app.post('/_api/pages.rename'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.rename);
+  app.post('/_api/pages.rename'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , csrf, page.api.rename);
+  app.post('/_api/pages.remove'       , loginRequired(crowi, app) , csrf, page.api.remove); // (Avoid from API Token)
+  app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.get('/_api/comments.get'        , accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.get);
   app.get('/_api/comments.get'        , accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.get);
-  app.post('/_api/comments.add'       , form.comment, accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.add);
+  app.post('/_api/comments.add'       , form.comment, accessTokenParser(crowi, app) , loginRequired(crowi, app) , csrf, comment.api.add);
   app.get( '/_api/bookmarks.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.get);
   app.get( '/_api/bookmarks.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.get);
-  app.post('/_api/bookmarks.add'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.add);
-  app.post('/_api/bookmarks.remove'   , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.remove);
-  app.post('/_api/likes.add'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.like);
-  app.post('/_api/likes.remove'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.unlike);
+  app.post('/_api/bookmarks.add'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , csrf, bookmark.api.add);
+  app.post('/_api/bookmarks.remove'   , accessTokenParser(crowi, app) , loginRequired(crowi, app) , csrf, bookmark.api.remove);
+  app.post('/_api/likes.add'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , csrf, page.api.like);
+  app.post('/_api/likes.remove'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , csrf, page.api.unlike);
 
 
   app.get( '/_api/revisions.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , revision.api.get);
   app.get( '/_api/revisions.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , revision.api.get);
   app.get( '/_api/revisions.list'     , accessTokenParser(crowi, app) , loginRequired(crowi, app) ,revision.api.list);
   app.get( '/_api/revisions.list'     , accessTokenParser(crowi, app) , loginRequired(crowi, app) ,revision.api.list);
@@ -112,8 +115,10 @@ module.exports = function(crowi, app) {
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
 
-  app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , page.pageEdit);
+  app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , csrf, page.pageEdit);
+  app.get('/trash/$'                 , loginRequired(crowi, app) , page.deletedPageListShow);
+  app.get('/trash/*/$'               , loginRequired(crowi, app) , page.deletedPageListShow);
   app.get('/*/$'                     , loginRequired(crowi, app) , page.pageListShow);
   app.get('/*/$'                     , loginRequired(crowi, app) , page.pageListShow);
   app.get('/*'                       , loginRequired(crowi, app) , page.pageShow);
   app.get('/*'                       , loginRequired(crowi, app) , page.pageShow);
-  //app.get('/*/edit'                , routes.edit);
+
 };
 };

+ 3 - 0
lib/routes/login.js

@@ -76,6 +76,9 @@ module.exports = function(crowi, app) {
         }
         }
       });
       });
     } else { // method GET
     } else { // method GET
+      if (req.form) {
+        debug(req.form.errors);
+      }
       return res.render('login', {
       return res.render('login', {
       });
       });
     }
     }

+ 126 - 15
lib/routes/page.js

@@ -62,6 +62,7 @@ module.exports = function(crowi, app) {
     var SEENER_THRESHOLD = 10;
     var SEENER_THRESHOLD = 10;
     path = path + (path == '/' ? '' : '/');
     path = path + (path == '/' ? '' : '/');
 
 
+    debug('Page list show', path);
     // index page
     // index page
     var pagerOptions = {
     var pagerOptions = {
       offset: offset,
       offset: offset,
@@ -76,12 +77,21 @@ module.exports = function(crowi, app) {
       page: null,
       page: null,
       path: path,
       path: path,
       pages: [],
       pages: [],
+      tree: [],
     };
     };
 
 
-    Page.hasPortalPage(path, req.user)
+    Page.hasPortalPage(path, req.user, req.query.revision)
     .then(function(portalPage) {
     .then(function(portalPage) {
       renderVars.page = portalPage;
       renderVars.page = portalPage;
 
 
+      if (portalPage) {
+        return Revision.findRevisionList(portalPage.path, {});
+      } else {
+        return Promise.resolve([]);
+      }
+    }).then(function(tree) {
+      renderVars.tree = tree;
+
       return Page.findListByStartWith(path, req.user, queryOptions);
       return Page.findListByStartWith(path, req.user, queryOptions);
     }).then(function(pageList) {
     }).then(function(pageList) {
 
 
@@ -91,8 +101,8 @@ module.exports = function(crowi, app) {
 
 
       pagerOptions.length = pageList.length;
       pagerOptions.length = pageList.length;
 
 
-      renderVars.config = {
-        seener_threshold: SEENER_THRESHOLD
+      renderVars.viewConfig = {
+        seener_threshold: SEENER_THRESHOLD,
       };
       };
       renderVars.pager = generatePager(pagerOptions);
       renderVars.pager = generatePager(pagerOptions);
       renderVars.pages = pageList;
       renderVars.pages = pageList;
@@ -102,6 +112,45 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  actions.deletedPageListShow = function(req, res) {
+    var path = '/trash' + getPathFromRequest(req);
+    var limit = 50;
+    var offset = parseInt(req.query.offset)  || 0;
+
+    // index page
+    var pagerOptions = {
+      offset: offset,
+      limit : limit
+    };
+    var queryOptions = {
+      offset: offset,
+      limit : limit + 1,
+      includeDeletedPage: true,
+    };
+
+    var renderVars = {
+      page: null,
+      path: path,
+      pages: [],
+    };
+
+    Page.findListByStartWith(path, req.user, queryOptions)
+    .then(function(pageList) {
+
+      if (pageList.length > limit) {
+        pageList.pop();
+      }
+
+      pagerOptions.length = pageList.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.pages = pageList;
+      res.render('page_list', renderVars);
+    }).catch(function(err) {
+      debug('Error on rendering deletedPageListShow', err);
+    });
+  };
+
   actions.search = function(req, res) {
   actions.search = function(req, res) {
     // spec: ?q=query&sort=sort_order&author=author_filter
     // spec: ?q=query&sort=sort_order&author=author_filter
     var query = req.query.q;
     var query = req.query.q;
@@ -238,14 +287,6 @@ module.exports = function(crowi, app) {
 
 
     res.locals.path = path;
     res.locals.path = path;
 
 
-    // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
-    // これ以前に定義されているはずなので、こうしてしまって問題ない。
-    if (!Page.isCreatableName(path)) {
-      debug('Page is not creatable name.', path);
-      res.redirect('/');
-      return ;
-    }
-
     Page.findPage(path, req.user, req.query.revision)
     Page.findPage(path, req.user, req.query.revision)
     .then(function(page) {
     .then(function(page) {
       debug('Page found', page._id, page.path);
       debug('Page found', page._id, page.path);
@@ -257,6 +298,16 @@ module.exports = function(crowi, app) {
 
 
       return renderPage(page, req, res);
       return renderPage(page, req, res);
     }).catch(function(err) {
     }).catch(function(err) {
+
+      // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
+      // これ以前に定義されているはずなので、こうしてしまって問題ない。
+      if (!Page.isCreatableName(path)) {
+        // 削除済みページの場合 /trash 以下に移動しているので creatableName になっていないので、表示を許可
+        debug('Page is not creatable name.', path);
+        res.redirect('/');
+        return ;
+      }
+
       if (req.query.revision) {
       if (req.query.revision) {
         return res.redirect(encodeURI(path));
         return res.redirect(encodeURI(path));
       }
       }
@@ -300,6 +351,7 @@ module.exports = function(crowi, app) {
     // set to render
     // set to render
     res.locals.pageForm = pageForm;
     res.locals.pageForm = pageForm;
 
 
+    // 削除済みページはここで編集不可判定される
     if (!Page.isCreatableName(path)) {
     if (!Page.isCreatableName(path)) {
       res.redirect(redirectPath);
       res.redirect(redirectPath);
       return ;
       return ;
@@ -378,7 +430,7 @@ module.exports = function(crowi, app) {
       if (user === null) {
       if (user === null) {
         throw new Error('The user not found.');
         throw new Error('The user not found.');
       }
       }
-      renderVars.user = user;
+      renderVars.pageUser = user;
 
 
       return Bookmark.findByUser(user, queryOptions);
       return Bookmark.findByUser(user, queryOptions);
     }).then(function(bookmarks) {
     }).then(function(bookmarks) {
@@ -416,7 +468,7 @@ module.exports = function(crowi, app) {
       if (user === null) {
       if (user === null) {
         throw new Error('The user not found.');
         throw new Error('The user not found.');
       }
       }
-      renderVars.user = user;
+      renderVars.pageUser = user;
 
 
       return Page.findListByCreator(user, queryOptions);
       return Page.findListByCreator(user, queryOptions);
     }).then(function(pages) {
     }).then(function(pages) {
@@ -585,9 +637,68 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  /**
+   * @api {post} /pages.remove Remove page
+   * @apiName RemovePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id
+   */
+  api.remove = function(req, res){
+    var pageId = req.body.page_id;
+    var previousRevision = req.body.revision_id || null;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+      debug('Delete page', pageData._id, pageData.path);
+
+      if (!pageData.isUpdatable(previousRevision)) {
+        throw new Error('Someone could update this page, so couldn\'t delete.');
+      }
+      return Page.deletePage(pageData, req.user);
+    }).then(function(data) {
+      debug('Page deleted', data.path);
+      var result = {};
+      result.page = data;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err, err.stack);
+      return res.json(ApiResponse.error('Failed to delete page.'));
+    });
+  };
+
+  /**
+   * @api {post} /pages.revertRemove Revert removed page
+   * @apiName RevertRemovePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  api.revertRemove = function(req, res){
+    var pageId = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+
+      // TODO: これでいいんだっけ
+      return Page.revertDeletedPage(pageData, req.user);
+    }).then(function(data) {
+      debug('Complete to revert deleted page', data.path);
+      var result = {};
+      result.page = data;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err, err.stack);
+      return res.json(ApiResponse.error('Failed to revert deleted page.'));
+    });
+  };
+
   /**
   /**
    * @api {post} /pages.rename Rename page
    * @api {post} /pages.rename Rename page
-   * @apiName SeenPage
+   * @apiName RenamePage
    * @apiGroup Page
    * @apiGroup Page
    *
    *
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} page_id Page Id.
@@ -620,7 +731,7 @@ module.exports = function(crowi, app) {
       .then(function(pageData) {
       .then(function(pageData) {
         page = pageData;
         page = pageData;
         if (!pageData.isUpdatable(previousRevision)) {
         if (!pageData.isUpdatable(previousRevision)) {
-          return res.json(ApiResponse.error('誰かが更新している可能性があります。ページを更新できません。'));
+          throw new Error('Someone could update this page, so couldn\'t delete.');
         }
         }
 
 
         return Page.rename(pageData, newPagePath, req.user, options);
         return Page.rename(pageData, newPagePath, req.user, options);

+ 20 - 5
lib/routes/revision.js

@@ -2,6 +2,7 @@ module.exports = function(crowi, app) {
   'use strict';
   'use strict';
 
 
   var debug = require('debug')('crowi:routes:revision')
   var debug = require('debug')('crowi:routes:revision')
+    , Page = crowi.model('Page')
     , Revision = crowi.model('Revision')
     , Revision = crowi.model('Revision')
     , ApiResponse = require('../util/apiResponse')
     , ApiResponse = require('../util/apiResponse')
     , actions = {}
     , actions = {}
@@ -34,18 +35,32 @@ module.exports = function(crowi, app) {
    * @apiGroup Revision
    * @apiGroup Revision
    *
    *
    * @apiParam {String} revision_ids Revision Ids.
    * @apiParam {String} revision_ids Revision Ids.
+   * @apiParam {String} page_id      Page Id.
    */
    */
   actions.api.list = function(req, res) {
   actions.api.list = function(req, res) {
-    var revisionIds = req.query.revision_ids.split(',');
+    var revisionIds = (req.query.revision_ids || '').split(',');
+    var pageId = req.query.page_id || null;
 
 
-    Revision
-      .findRevisions(revisionIds)
+    if (pageId) {
+      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      .then(function(pageData) {
+        debug('Page found', pageData._id, pageData.path);
+        return Revision.findRevisionList(pageData.path, {});
+      }).then(function(revisions) {
+        return res.json(ApiResponse.success(revisions));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+    } else if (revisionIds.length > 0) {
+      Revision.findRevisions(revisionIds)
       .then(function(revisions) {
       .then(function(revisions) {
         return res.json(ApiResponse.success(revisions));
         return res.json(ApiResponse.success(revisions));
-      })
-      .catch(function(err) {
+      }).catch(function(err) {
         return res.json(ApiResponse.error(err));
         return res.json(ApiResponse.error(err));
       });
       });
+    } else {
+      return res.json(ApiResponse.error('Parameter error.'));
+    }
   };
   };
 
 
   return actions;
   return actions;

+ 26 - 2
lib/util/middlewares.js

@@ -3,6 +3,11 @@ var debug = require('debug')('crowi:lib:middlewares');
 exports.loginChecker = function(crowi, app) {
 exports.loginChecker = function(crowi, app) {
   return function(req, res, next) {
   return function(req, res, next) {
     var User = crowi.model('User');
     var User = crowi.model('User');
+    var csrfKey = (req.session && req.session.id) || 'anon';
+
+    if (req.csrfToken === null) {
+      req.csrfToken = crowi.getTokens().create(csrfKey);
+    }
 
 
     // session に user object が入ってる
     // session に user object が入ってる
     if (req.session.user && '_id' in req.session.user) {
     if (req.session.user && '_id' in req.session.user) {
@@ -23,9 +28,26 @@ exports.loginChecker = function(crowi, app) {
   };
   };
 };
 };
 
 
+exports.csrfVerify = function(crowi, app) {
+  return function(req, res, next) {
+    var token = req.body._csrf || req.query._csrf || null;
+    var csrfKey = (req.session && req.session.id) || 'anon';
+
+    if (req.skipCsrfVerify) {
+      return next();
+    }
+
+    if (crowi.getTokens().verify(csrfKey, token)) {
+      return next();
+    }
+
+    return res.sendStatus(403);
+  };
+};
+
 exports.swigFunctions = function(crowi, app) {
 exports.swigFunctions = function(crowi, app) {
   return function(req, res, next) {
   return function(req, res, next) {
-    require('../util/swigFunctions')(crowi, app, res.locals);
+    require('../util/swigFunctions')(crowi, app, req, res.locals);
     next();
     next();
   };
   };
 };
 };
@@ -138,7 +160,7 @@ exports.loginRequired = function(crowi, app) {
 
 
 exports.accessTokenParser = function(crowi, app) {
 exports.accessTokenParser = function(crowi, app) {
   return function(req, res, next) {
   return function(req, res, next) {
-    var accessToken = req.query.access_token;
+    var accessToken = req.query.access_token || req.body.access_token || null;
     if (!accessToken) {
     if (!accessToken) {
       return next();
       return next();
     }
     }
@@ -148,6 +170,8 @@ exports.accessTokenParser = function(crowi, app) {
     User.findUserByApiToken(accessToken)
     User.findUserByApiToken(accessToken)
     .then(function(userData) {
     .then(function(userData) {
       req.user = userData;
       req.user = userData;
+      req.skipCsrfVerify = true;
+
       next();
       next();
     }).catch(function(err) {
     }).catch(function(err) {
       next();
       next();

+ 21 - 2
lib/util/search.js

@@ -34,6 +34,7 @@ SearchClient.prototype.registerUpdateEvent = function() {
   var pageEvent = this.crowi.event('page');
   var pageEvent = this.crowi.event('page');
   pageEvent.on('create', this.syncPageCreated.bind(this))
   pageEvent.on('create', this.syncPageCreated.bind(this))
   pageEvent.on('update', this.syncPageUpdated.bind(this))
   pageEvent.on('update', this.syncPageUpdated.bind(this))
+  pageEvent.on('delete', this.syncPageDeleted.bind(this))
 };
 };
 
 
 SearchClient.prototype.shouldIndexed = function(page) {
 SearchClient.prototype.shouldIndexed = function(page) {
@@ -46,6 +47,10 @@ SearchClient.prototype.shouldIndexed = function(page) {
     return false;
     return false;
   }
   }
 
 
+  if (page.isDeleted()) {
+    return false;
+  }
+
   return true;
   return true;
 };
 };
 
 
@@ -390,7 +395,7 @@ SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option)
 
 
 SearchClient.prototype.syncPageCreated = function(page, user)
 SearchClient.prototype.syncPageCreated = function(page, user)
 {
 {
-  debug('SearchClient.syncPageCreated', page);
+  debug('SearchClient.syncPageCreated', page.path);
 
 
   if (!this.shouldIndexed(page)) {
   if (!this.shouldIndexed(page)) {
     return ;
     return ;
@@ -407,7 +412,7 @@ SearchClient.prototype.syncPageCreated = function(page, user)
 
 
 SearchClient.prototype.syncPageUpdated = function(page, user)
 SearchClient.prototype.syncPageUpdated = function(page, user)
 {
 {
-  debug('SearchClient.syncPageUpdated', page);
+  debug('SearchClient.syncPageUpdated', page.path);
   // TODO delete
   // TODO delete
   if (!this.shouldIndexed(page)) {
   if (!this.shouldIndexed(page)) {
     this.deletePages([page])
     this.deletePages([page])
@@ -430,5 +435,19 @@ SearchClient.prototype.syncPageUpdated = function(page, user)
   });
   });
 };
 };
 
 
+SearchClient.prototype.syncPageDeleted = function(page, user)
+{
+  debug('SearchClient.syncPageDeleted', page.path);
+
+  this.deletePages([page])
+  .then(function(res) {
+    debug('deletePages: ES Response', res);
+  })
+  .catch(function(err){
+    debug('deletePages:ES Error', err);
+  });
+
+  return ;
+};
 
 
 module.exports = SearchClient;
 module.exports = SearchClient;

+ 24 - 1
lib/util/swigFunctions.js

@@ -1,10 +1,15 @@
-module.exports = function(crowi, app, locals) {
+module.exports = function(crowi, app, req, locals) {
   var debug = require('debug')('crowi:lib:swigFunctions')
   var debug = require('debug')('crowi:lib:swigFunctions')
     , Page = crowi.model('Page')
     , Page = crowi.model('Page')
     , Config = crowi.model('Config')
     , Config = crowi.model('Config')
     , User = crowi.model('User')
     , User = crowi.model('User')
   ;
   ;
 
 
+  // token getter
+  locals._csrf = function() {
+    return req.csrfToken;
+  };
+
   locals.facebookLoginEnabled = function() {
   locals.facebookLoginEnabled = function() {
     var config = crowi.getConfig()
     var config = crowi.getConfig()
     return config.crowi['facebook:appId'] && config.crowi['facebook:secret'];
     return config.crowi['facebook:appId'] && config.crowi['facebook:secret'];
@@ -43,6 +48,24 @@ module.exports = function(crowi, app, locals) {
     return false;
     return false;
   };
   };
 
 
+  locals.isTopPage = function() {
+    var path = req.path || '';
+    if (path === '/') {
+      return true;
+    }
+
+    return false;
+  };
+
+  locals.isTrashPage = function() {
+    var path = req.path || '';
+    if (path.match(/^\/trash\/.*/)) {
+      return true;
+    }
+
+    return false;
+  };
+
   locals.userPageRoot = function(user) {
   locals.userPageRoot = function(user) {
     if (!user || !user.username) {
     if (!user || !user.username) {
       return '';
       return '';

+ 1 - 1
lib/views/500.html

@@ -1 +1 @@
-Error: {{ error }}
+Error: {{ error.message }}

+ 1 - 0
lib/views/_form.html

@@ -49,6 +49,7 @@
           {% endfor %}
           {% endfor %}
         </select>
         </select>
         {% endif %}
         {% endif %}
+        <input type="hidden" name="_csrf" value="{{ _csrf() }}">
         <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
         <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
       </div>
       </div>
     </div>
     </div>

+ 6 - 1
lib/views/admin/app.html

@@ -54,6 +54,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ _csrf() }}">
             <button type="submit" class="btn btn-primary">更新</button>
             <button type="submit" class="btn btn-primary">更新</button>
           </div>
           </div>
         </div>
         </div>
@@ -105,6 +106,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ _csrf() }}">
             <button type="submit" class="btn btn-primary">更新</button>
             <button type="submit" class="btn btn-primary">更新</button>
           </div>
           </div>
         </div>
         </div>
@@ -149,6 +151,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ _csrf() }}">
             <button type="submit" class="btn btn-primary">更新</button>
             <button type="submit" class="btn btn-primary">更新</button>
           </div>
           </div>
         </div>
         </div>
@@ -197,6 +200,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ _csrf() }}">
             <button type="submit" class="btn btn-primary">更新</button>
             <button type="submit" class="btn btn-primary">更新</button>
           </div>
           </div>
         </div>
         </div>
@@ -225,6 +229,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ _csrf() }}">
             <button type="submit" class="btn btn-primary">更新</button>
             <button type="submit" class="btn btn-primary">更新</button>
           </div>
           </div>
         </div>
         </div>
@@ -253,6 +258,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ _csrf() }}">
             <button type="submit" class="btn btn-primary">更新</button>
             <button type="submit" class="btn btn-primary">更新</button>
           </div>
           </div>
         </div>
         </div>
@@ -297,7 +303,6 @@
           $button.attr('disabled', 'disabled');
           $button.attr('disabled', 'disabled');
           var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
           var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
             {
             {
-              console.log(data);
               if (data.status) {
               if (data.status) {
                 showMessage($id, '更新しました');
                 showMessage($id, '更新しました');
               } else {
               } else {

+ 3 - 0
lib/views/admin/notification.html

@@ -65,6 +65,7 @@
           </div>
           </div>
         </div>
         </div>
       </fieldset>
       </fieldset>
+      <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       </form>
       </form>
 
 
       {% if hasSlackConfig %}
       {% if hasSlackConfig %}
@@ -108,6 +109,7 @@
               </p>
               </p>
             </td>
             </td>
             <td>
             <td>
+              <input type="hidden" name="_csrf" value="{{ _csrf() }}">
               <input type="submit" value="Add" class="btn btn-primary">
               <input type="submit" value="Add" class="btn btn-primary">
             </td>
             </td>
           </tr>
           </tr>
@@ -124,6 +126,7 @@
             <td>
             <td>
               <form class="admin-remove-updatepost">
               <form class="admin-remove-updatepost">
                 <input type="hidden" name="id" value="{{ notif._id.toString() }}">
                 <input type="hidden" name="id" value="{{ notif._id.toString() }}">
+                <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                 <input type="submit" value="Delete" class="btn btn-default">
                 <input type="submit" value="Delete" class="btn btn-default">
               </form>
               </form>
             </td>
             </td>

+ 1 - 0
lib/views/admin/search.html

@@ -51,6 +51,7 @@
           </div>
           </div>
         </div>
         </div>
       </fieldset>
       </fieldset>
+      <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       </form>
       </form>
 
 
     </div>
     </div>

+ 8 - 0
lib/views/admin/users.html

@@ -48,6 +48,7 @@
           </div>
           </div>
           <button type="submit" class="btn btn-primary">招待する</button>
           <button type="submit" class="btn btn-primary">招待する</button>
         </div>
         </div>
+        <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       </form>
       </form>
 
 
       {% set createdUser = req.flash('createdUser') %}
       {% set createdUser = req.flash('createdUser') %}
@@ -126,28 +127,33 @@
                   <li class="dropdown-button">
                   <li class="dropdown-button">
                   {% if sUser.status == 1 %}
                   {% if sUser.status == 1 %}
                   <form action="/admin/user/{{ sUser._id.toString() }}/activate" method="post">
                   <form action="/admin/user/{{ sUser._id.toString() }}/activate" method="post">
+                    <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                     <button type="submit" class="btn btn-block btn-info">承認する</button>
                     <button type="submit" class="btn btn-block btn-info">承認する</button>
                   </form>
                   </form>
                   {% endif  %}
                   {% endif  %}
                   {% if sUser.status == 2 %}
                   {% if sUser.status == 2 %}
                   <form action="/admin/user/{{ sUser._id.toString() }}/suspend" method="post">
                   <form action="/admin/user/{{ sUser._id.toString() }}/suspend" method="post">
+                    <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                     <button type="submit" class="btn btn-block btn-warning">アカウント停止</button>
                     <button type="submit" class="btn btn-block btn-warning">アカウント停止</button>
                   </form>
                   </form>
                   {% endif  %}
                   {% endif  %}
                   {% if sUser.status == 3 %}
                   {% if sUser.status == 3 %}
                   <form action="/admin/user/{{ sUser._id.toString() }}/activate" method="post">
                   <form action="/admin/user/{{ sUser._id.toString() }}/activate" method="post">
+                    <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                     <button type="submit" class="btn btn-block btn-default">元に戻す</button>
                     <button type="submit" class="btn btn-block btn-default">元に戻す</button>
                   </form>
                   </form>
                   </li>
                   </li>
                   <li class="dropdown-button">
                   <li class="dropdown-button">
                   {# label は同じだけど、こっちは論理削除 #}
                   {# label は同じだけど、こっちは論理削除 #}
                   <form action="/admin/user/{{ sUser._id.toString() }}/remove" method="post">
                   <form action="/admin/user/{{ sUser._id.toString() }}/remove" method="post">
+                    <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                     <button type="submit" class="btn btn-block btn-danger">削除する</button>
                     <button type="submit" class="btn btn-block btn-danger">削除する</button>
                   </form>
                   </form>
                   {% endif  %}
                   {% endif  %}
                   {% if sUser.status == 5 %}
                   {% if sUser.status == 5 %}
                   {# label は同じだけど、こっちは物理削除 #}
                   {# label は同じだけど、こっちは物理削除 #}
                   <form action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
                   <form action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
+                    <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                     <button type="submit" class="btn btn-block btn-danger">削除する</button>
                     <button type="submit" class="btn btn-block btn-danger">削除する</button>
                   </form>
                   </form>
                   {% endif  %}
                   {% endif  %}
@@ -161,6 +167,7 @@
                     {% if sUser.admin %}
                     {% if sUser.admin %}
                       {% if sUser.username != user.username %}
                       {% if sUser.username != user.username %}
                       <form action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">
                       <form action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">
+                        <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                         <button type="submit" class="btn btn-block btn-danger">管理者からはずす</button>
                         <button type="submit" class="btn btn-block btn-danger">管理者からはずす</button>
                       </form>
                       </form>
                       {% else %}
                       {% else %}
@@ -168,6 +175,7 @@
                       {% endif %}
                       {% endif %}
                     {% else %}
                     {% else %}
                       <form action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
                       <form action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
+                        <input type="hidden" name="_csrf" value="{{ _csrf() }}">
                         <button type="submit" class="btn btn-block btn-primary">管理者にする</button>
                         <button type="submit" class="btn btn-block btn-primary">管理者にする</button>
                       </form>
                       </form>
                     {% endif %}
                     {% endif %}

+ 1 - 0
lib/views/installer.html

@@ -65,6 +65,7 @@
       パスワードは6文字以上の半角英数字または記号
       パスワードは6文字以上の半角英数字または記号
       </p>
       </p>
 
 
+      <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="作成">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="作成">
     </form>
     </form>
 
 

+ 1 - 0
lib/views/invited.html

@@ -80,6 +80,7 @@
       パスワードは6文字以上の半角英数字または記号
       パスワードは6文字以上の半角英数字または記号
       </p>
       </p>
 
 
+      <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="登録を完了">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="登録を完了">
     </form>
     </form>
 
 

+ 1 - 1
lib/views/layout/2column.html

@@ -20,7 +20,7 @@
     <footer class="">
     <footer class="">
       <p>
       <p>
       <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
       <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
-      &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
+      &copy; {{ now|date('Y') }} {{ config.crowi['app:title']|default('Crowi') }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
     </footer>
     </footer>
   </div>
   </div>
 </aside>
 </aside>

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

@@ -104,6 +104,8 @@
           <li class="divider"></li>
           <li class="divider"></li>
           <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
           <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
           <li class="divider"></li>
           <li class="divider"></li>
+          <li><a href="/trash/"><i class="fa fa-trash-o"></i> 削除済みページ</a></li>
+          <li class="divider"></li>
           <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
           <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
           {# <li><a href="#">今日の日報を作成</a></li> #}
           {# <li><a href="#">今日の日報を作成</a></li> #}
           {# <li class="divider"></li> #}
           {# <li class="divider"></li> #}

+ 5 - 0
lib/views/login.html

@@ -49,6 +49,7 @@
         <input type="password" class="form-control" placeholder="Password" name="loginForm[password]">
         <input type="password" class="form-control" placeholder="Password" name="loginForm[password]">
       </div>
       </div>
 
 
+      <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="Login">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="Login">
     </form>
     </form>
 
 
@@ -60,6 +61,7 @@
         <p>Google でログイン</p>
         <p>Google でログイン</p>
         <form role="form" action="/login/google" method="get">
         <form role="form" action="/login/google" method="get">
           <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
           <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
+          <input type="hidden" name="_csrf" value="{{ _csrf() }}">
         </form>
         </form>
       </div>
       </div>
       {% endif %}
       {% endif %}
@@ -67,6 +69,7 @@
       <div class="col-md-6">
       <div class="col-md-6">
         <p>Facebook でログイン</p>
         <p>Facebook でログイン</p>
         <form role="form">
         <form role="form">
+          <input type="hidden" name="_csrf" value="{{ _csrf() }}">
           <button type="button" id="btn-login-facebook" class="btn btn-block btn-facebook"><i class="fa fa-facebook-square"></i> Login</button>
           <button type="button" id="btn-login-facebook" class="btn btn-block btn-facebook"><i class="fa fa-facebook-square"></i> Login</button>
         </form>
         </form>
       </div>
       </div>
@@ -163,6 +166,7 @@
       パスワードは6文字以上の半角英数字または記号
       パスワードは6文字以上の半角英数字または記号
       </p>
       </p>
 
 
+      <input type="hidden" name="_csrf" value="{{ _csrf() }}">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="新規登録">
       <input type="submit" class="btn btn-primary btn-lg btn-block" value="新規登録">
     </form>
     </form>
 
 
@@ -173,6 +177,7 @@
       <div class="col-md-6">
       <div class="col-md-6">
         <p>Google で登録</p>
         <p>Google で登録</p>
         <form role="form" method="post" action="/register/google">
         <form role="form" method="post" action="/register/google">
+          <input type="hidden" name="_csrf" value="{{ _csrf() }}">
           <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
           <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
         </form>
         </form>
       </div>
       </div>

+ 32 - 0
lib/views/modal/widget_delete.html

@@ -0,0 +1,32 @@
+  <div class="modal fade" id="deletePage">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+      <form role="form" id="delete-page-form" onsubmit="return false;">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title"><i class="fa fa-trash-o"></i> Delete Page</h4>
+        </div>
+        <div class="modal-body">
+          <ul>
+            <li>This page will be moved to the <a href="/trash/">trash</a>.</li>
+          </ul>
+            <div class="form-group">
+              <label for="">This page:</label><br>
+              <code>{{ page.path }}</code>
+            </div>
+        </div>
+        <div class="modal-footer">
+          <p><small class="pull-left" id="delete-errors"></small></p>
+          <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() }}">
+          <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
+          <input type="submit" class="btn btn-danger" value="Delete!">
+        </div>
+
+      </form>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+  </div><!-- /.modal -->

+ 1 - 0
lib/views/modal/widget_rename.html

@@ -40,6 +40,7 @@
         </div>
         </div>
         <div class="modal-footer">
         <div class="modal-footer">
           <p><small class="pull-left" id="newPageNameCheck"></small></p>
           <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <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" 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() }}">

+ 47 - 0
lib/views/modal/widget_unportalize.html

@@ -0,0 +1,47 @@
+{% if isTopPage() %}
+  {% set unportalizedPath = '/top-' + Date.now() %}
+{% else %}
+  {% set unportalizedPath = page.path|replace('(\/)$', '') %}
+{% endif %}
+  <div class="modal fade" id="unportalize">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+      <form role="form" id="unportalize-form" onsubmit="return false;">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title">ポータル化を解除する</h4>
+        </div>
+        <div class="modal-body">
+          <ul>
+           <li>このポータル化を解除し、通常のページに戻します。</li>
+          </ul>
+            <div class="form-group">
+              <p>
+                <label for="">このページ</label><br><code>{{ page.path }}</code>
+              </p>
+              <p>
+                <label for="">解除後のページ</label><br><code>{{ unportalizedPath }}</code>
+              </p>
+              {% if isTopPage() %}
+              <p class="alert alert-info">
+              このポータルはトップページのポータルのため、特別なページに移動します。
+              </p>
+              {% endif %}
+            </div>
+        </div>
+        <div class="modal-footer">
+          <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <input type="hidden" name="_csrf" value="{{ _csrf() }}">
+          <input type="hidden" name="path" value="{{ page.path }}">
+          <input type="hidden" class="form-control" name="new_path" id="newPageName" value="{{ unportalizedPath }}">
+          <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+          <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
+          <input type="submit" class="btn btn-primary" value="Unportalize!">
+        </div>
+
+      </form>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+  </div><!-- /.modal -->

+ 49 - 7
lib/views/page.html

@@ -8,15 +8,23 @@
 {% endblock %}
 {% endblock %}
 
 
 <div class="header-wrap">
 <div class="header-wrap">
+  {% if not page.isDeleted() %}
   <header id="page-header">
   <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
 
 
 
     {% if page %}
     {% if page %}
-      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ _csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
     {% endif %}
     {% endif %}
     <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
     <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
   </header>
   </header>
+  {% else %}
+  {# trash/* #}
+  <header id="page-header">
+    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ _csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    <h1 class="title">{{ path|insertSpaceToEachSlashes }}</h1>
+  </header>
+  {% endif %}
 </div>
 </div>
 
 
 {% block content_head_after %}
 {% block content_head_after %}
@@ -54,8 +62,26 @@
 
 
   {% else %}
   {% else %}
 
 
-  <ul class="nav nav-tabs hidden-print">
+  {% if page.isDeleted() %}
+  <div class="alert alert-danger">
+    <form role="form" class="pull-right" id="revert-delete-page-form" onsubmit="return false;">
+      <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() }}">
+      <input type="submit" class="btn btn-danger btn-inverse btn-sm" value="Put Back!">
+    </form>
+    <p>
+      <i class="fa fa-trash-o" aria-hidden="true"></i>
+      This page is in the trash.<br>
+    </p>
+    <p>
+    Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm picture-rounded"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+    </p>
+  </div>
+  {% endif %}
 
 
+  {% if not page.isDeleted() %}
+  <ul class="nav nav-tabs hidden-print">
     <li class=" {% if not req.body.pageForm %}active{% endif %}" data-toggle="tooltip" {# data-title="あなたの 確認待ち です" title="" data-placement="bottom" data-trigger="manual" data-tooltip-stay #}>
     <li class=" {% if not req.body.pageForm %}active{% endif %}" data-toggle="tooltip" {# data-title="あなたの 確認待ち です" title="" data-placement="bottom" data-trigger="manual" data-tooltip-stay #}>
       <a href="#revision-body" data-toggle="tab">
       <a href="#revision-body" data-toggle="tab">
       <i class="fa fa-magic"></i>
       <i class="fa fa-magic"></i>
@@ -76,24 +102,28 @@
       <ul class="dropdown-menu">
       <ul class="dropdown-menu">
        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="fa fa-share"></i> 移動</a></li>
        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="fa fa-share"></i> 移動</a></li>
        <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</a></li>
        <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</a></li>
+       <li class="divider"></li>
+       <li class=""><a href="#" data-target="#deletePage" data-toggle="modal"><i class="fa fa-trash-o text-danger"></i> 削除</a></li>
       </ul>
       </ul>
     </li>
     </li>
     {% if page %}
     {% if page %}
     <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
     <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
     {% endif %}
     {% endif %}
   </ul>
   </ul>
+  {% endif %}
 
 
   {% include 'modal/widget_rename.html' %}
   {% include 'modal/widget_rename.html' %}
+  {% include 'modal/widget_delete.html' %}
 
 
   <div class="tab-content wiki-content">
   <div class="tab-content wiki-content">
-  {% if req.query.renamed %}
+  {% if req.query.renamed and not page.isDeleted() %}
   <div class="alert alert-info">
   <div class="alert alert-info">
     <strong>移動しました: </strong> このページは <code>{{ req.query.renamed }}</code> から移動しました。
     <strong>移動しました: </strong> このページは <code>{{ req.query.renamed }}</code> から移動しました。
   </div>
   </div>
   {% endif %}
   {% endif %}
   {% if not page.isLatestRevision() %}
   {% if not page.isLatestRevision() %}
   <div class="alert alert-warning">
   <div class="alert alert-warning">
-    <strong>注意: </strong> これは現在の版ではありません。
+    <strong>注意: </strong> これは現在の版ではありません。 <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のページを表示</a>
   </div>
   </div>
   {% endif %}
   {% endif %}
 
 
@@ -117,9 +147,11 @@
     </div>
     </div>
 
 
     {# edit form #}
     {# edit form #}
+    {% if not page.isDeleted() %}
     <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
     <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' %}
       {% include '_form.html' %}
     </div>
     </div>
+    {% endif %}
 
 
     {# raw revision history #}
     {# raw revision history #}
     <div class="tab-pane revision-history" id="revision-history">
     <div class="tab-pane revision-history" id="revision-history">
@@ -175,18 +207,28 @@
 
 
 <p class="meta">
 <p class="meta">
   Path: <span id="pagePath">{{ page.path }}</span><br>
   Path: <span id="pagePath">{{ page.path }}</span><br>
-  Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture picture-rounded"> {{ page.revision.author.name }}<br>
+  {# for BC #}
+  {% if page.lastUpdateUser %}
+    Last updated at {{ page.updatedAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-rounded"> {{ page.lastUpdateUser.name }}<br>
+  {% else %}
+    Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture picture-rounded"> {{ page.revision.author.name }}<br>
+  {% endif %}
+  {# /for BC #}
   Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-rounded"> {{ page.creator.name }}<br>
   Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-rounded"> {{ page.creator.name }}<br>
 </p>
 </p>
 
 
 {% endblock %}
 {% endblock %}
 
 
 {% block side_header %}
 {% block side_header %}
-  {% include 'widget/page_side_header.html' %}
+  {% if not page.isDeleted() %}
+    {% include 'widget/page_side_header.html' %}
+  {% endif %}
 {% endblock %} {# side_header #}
 {% endblock %} {# side_header #}
 
 
 {% block side_content %}
 {% block side_content %}
-  {% include 'widget/page_side_content.html' %}
+  {% if not page.isDeleted() %}
+    {% include 'widget/page_side_content.html' %}
+  {% endif %}
 {% endblock %}
 {% endblock %}
 
 
 {% block footer %}
 {% block footer %}

+ 52 - 5
lib/views/page_list.html

@@ -15,12 +15,12 @@
 <div class="header-wrap">
 <div class="header-wrap">
   <header class="portal-header {% if page %}has-page{% endif %}">
   <header class="portal-header {% if page %}has-page{% endif %}">
     {% if page %}
     {% if page %}
-      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ _csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
 
 
     {% endif %}
     {% endif %}
     <h1 class="title">
     <h1 class="title">
       <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
       <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
-      {% if searchConfigured() && path != '/' %}
+      {% if searchConfigured() && !isTopPage() && !isTrashPage() %}
       <form class="input-group search-input-group hidden-xs hidden-sm" data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索" id="search-listpage-form">
       <form class="input-group search-input-group hidden-xs hidden-sm" data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索" id="search-listpage-form">
         <input type="text" class="search-listpage-input form-control" data-path="{{ path }}" id="search-listpage-input">
         <input type="text" class="search-listpage-input form-control" data-path="{{ path }}" id="search-listpage-input">
         <span class="input-group-btn search-listpage-submit-group">
         <span class="input-group-btn search-listpage-submit-group">
@@ -46,7 +46,7 @@
  # but now the header and page list content is rendered separately by the server,
  # but now the header and page list content is rendered separately by the server,
  # so now bind the values through the hidden fields.
  # so now bind the values through the hidden fields.
  #}
  #}
-{% if searchConfigured() && path != '/' %}
+{% if searchConfigured() && !isTopPage() && !isTrashPage() %}
 <div id="page-list-search">
 <div id="page-list-search">
 </div>
 </div>
 {% endif %}
 {% endif %}
@@ -85,10 +85,26 @@
         <i class="fa fa-times"></i>
         <i class="fa fa-times"></i>
       </a>
       </a>
     </li>
     </li>
+    {% else %}
+    <li class="dropdown pull-right">
+      <a class="dropdown-toggle" data-toggle="dropdown" href="#">
+        <i class="fa fa-wrench"></i> <span class="caret"></span>
+      </a>
+      <ul class="dropdown-menu">
+       <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> ポータル解除</a></li>
+      </ul>
+    </li>
+    <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
     {% endif %}
     {% endif %}
   </ul>
   </ul>
+  {% include 'modal/widget_unportalize.html' %}
 
 
   <div class="tab-content">
   <div class="tab-content">
+  {% if page and not page.isLatestRevision() %}
+  <div class="alert alert-warning">
+    <strong>注意: </strong> これは現在の版ではありません。 <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のポータルを表示</a>
+  </div>
+  {% endif %}
     <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">{{ page.revision.body|nl2br|safe }}</div>
     <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">{{ page.revision.body|nl2br|safe }}</div>
 
 
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
@@ -96,7 +112,38 @@
     <div class="tab-pane edit-form portal-form {% if req.body.pageForm %}active{% endif %}" id="edit-form">
     <div class="tab-pane edit-form portal-form {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' with {forceGrant: 1} %}
       {% include '_form.html' with {forceGrant: 1} %}
     </div>
     </div>
+
+    <div class="tab-pane revision-history" id="revision-history">
+      <h1><i class="fa fa-history"></i> History</h1>
+      {% if not page %}
+      {% else %}
+      <div class="revision-history-list">
+        {% for t in tree %}
+        <div class="revision-hisory-outer">
+          <img src="{{ t.author|picture }}" class="picture picture-rounded">
+          <div class="revision-history-main">
+            <div class="revision-history-author">
+              <strong>{% if t.author %}{{ t.author.username }}{% else %}-{% endif %}</strong>
+            </div>
+            <div class="revision-history-comment">
+            </div>
+            <div class="revision-history-meta">
+              {{ t.createdAt|datetz('Y-m-d H:i:s') }}
+              <br>
+              <a href="?revision={{ t._id.toString() }}"><i class="fa fa-history"></i> このバージョンを見る</a>
+              <a class="diff-view" data-revision-id="{{ t._id.toString() }}">
+                <i id="diff-icon-{{ t._id.toString() }}" class="fa fa-arrow-circle-right"></i> 差分を見る
+              </a>
+              <pre class="" id="diff-display-{{ t._id.toString()}}" style="display: none"></pre>
+            </div>
+          </div>
+        </div>
+        {% endfor %}
+      </div>
+      {% endif %}
+    </div>
   </div>
   </div>
+
 </div> {# /.portal #}
 </div> {# /.portal #}
 
 
 <div class="page-list-container">
 <div class="page-list-container">
@@ -127,7 +174,7 @@
 
 
     {# list view #}
     {# list view #}
     <div class="active tab-pane fade page-list-container in" id="view-list">
     <div class="active tab-pane fade page-list-container in" id="view-list">
-      {% include 'widget/page_list.html' with { pages: pages, pager: pager, config: config } %}
+      {% include 'widget/page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
     </div>
     </div>
 
 
     {# timeline view #}
     {# timeline view #}
@@ -163,7 +210,7 @@
 
 
 {% block side_header %}
 {% block side_header %}
 
 
-{% if not page and not isUserPageList(path) %}
+{% if not page and not isUserPageList(path) and !isTrashPage() %}
 <div class="portal-side">
 <div class="portal-side">
   <div class="portal-form-button">
   <div class="portal-form-button">
     <button class="btn btn-primary" id="create-portal-button">Create Portal</button>
     <button class="btn btn-primary" id="create-portal-button">Create Portal</button>

+ 2 - 2
lib/views/user/bookmarks.html

@@ -2,14 +2,14 @@
 
 
 {% block main_css_class %}bookmark-page{% endblock %}
 {% block main_css_class %}bookmark-page{% endblock %}
 
 
-{% block html_title %}{{ user.name }}'s Bookmarks · {% endblock %}
+{% block html_title %}{{ pageUser.name }}'s Bookmarks · {% endblock %}
 
 
 {% block content_head %}
 {% block content_head %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
 
-    <h1 class="title">{{ user.name }}'s Bookmarks</h1>
+    <h1 class="title">{{ pageUser.name }}'s Bookmarks</h1>
   </header>
   </header>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 2 - 2
lib/views/user/recent-create.html

@@ -2,14 +2,14 @@
 
 
 {% block main_css_class %}recent-create-page{% endblock %}
 {% block main_css_class %}recent-create-page{% endblock %}
 
 
-{% block html_title %}{{ user.name }}'s Recent Created Pages 揃 {% endblock %}
+{% block html_title %}{{ pageUser.name }}'s Recent Created Pages 揃 {% endblock %}
 
 
 {% block content_head %}
 {% block content_head %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
 
-    <h1 class="title">{{ user.name }}'s Created Pages</h1>
+    <h1 class="title">{{ pageUser.name }}'s Created Pages</h1>
   </header>
   </header>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 2 - 2
lib/views/widget/page_list.html

@@ -31,9 +31,9 @@
     </span>
     </span>
     {% endif  %}
     {% endif  %}
 
 
-    {% if page.seenUsers.length >= config.seener_threshold %}
+    {% if viewConfig.seener_threshold and page.seenUsers.length >= viewConfig.seener_threshold %}
     <span>
     <span>
-      <i class="fa fa-eye">{{ page.seenUsers.length }}</i>
+      <i class="fa fa-eye"></i>{{ page.seenUsers.length }}
     </span>
     </span>
     {% endif  %}
     {% endif  %}
 
 

+ 3 - 2
lib/views/widget/page_side_content.html

@@ -2,11 +2,11 @@
 <ul class="fitted-list">
 <ul class="fitted-list">
   <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
   <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
     <span class="input-group-addon">共有用</span>
     <span class="input-group-addon">共有用</span>
-    <input class="copy-link form-control" type="text" value="{{ config.crowi['app:title'] }} {{ path }}  {{ baseUrl }}/{{ page._id.toString() }}">
+    <input readonly class="copy-link form-control" type="text" value="{{ config.crowi['app:title']|default('Crowi') }} {{ path }}  {{ baseUrl }}/{{ page._id.toString() }}">
   </li>
   </li>
   <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
   <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
     <span class="input-group-addon">Markdown</span>
     <span class="input-group-addon">Markdown</span>
-    <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/{{ page._id.toString() }})">
+    <input readonly class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/{{ page._id.toString() }})">
   </li>
   </li>
 </ul>
 </ul>
 
 
@@ -19,6 +19,7 @@
           <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"></textarea>
           <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"></textarea>
         </div>
         </div>
         <div class="comment-submit">
         <div class="comment-submit">
+          <input type="hidden" name="_csrf" value="{{ _csrf() }}">
           <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
           <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
           <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
           <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
           <span class="text-danger" id="comment-form-message"></span>
           <span class="text-danger" id="comment-form-message"></span>

+ 8 - 1
lib/views/widget/page_side_header.html

@@ -13,7 +13,13 @@
       </p>
       </p>
       <p class="created-at">
       <p class="created-at">
         作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
         作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
-        最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
+
+        {% if page.lastUpdateUser %}
+          最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.lastUpdateUser.username }}"><img src="{{ page.lastUpdateUser|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.lastUpdateUser.name }}"></a>
+        {% else %}
+          {# for BC 1.5.x #}
+          最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
+        {% endif %}
       </p>
       </p>
     </div>
     </div>
   </div>
   </div>
@@ -27,6 +33,7 @@
         <p class="liker-count">
         <p class="liker-count">
         <span id="like-count">{{ page.liker.length }}</span>
         <span id="like-count">{{ page.liker.length }}</span>
         <button
         <button
+          data-csrftoken="{{ _csrf() }}"
           data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
           data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
           class="btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
           class="btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
           id="like-button"><i class="fa fa-thumbs-o-up"></i> いいね!</button>
           id="like-button"><i class="fa fa-thumbs-o-up"></i> いいね!</button>

+ 1 - 0
package.json

@@ -48,6 +48,7 @@
     "connect-redis": "~2.1.0",
     "connect-redis": "~2.1.0",
     "consolidate": "~0.11.0",
     "consolidate": "~0.11.0",
     "cookie-parser": "~1.3.4",
     "cookie-parser": "~1.3.4",
+    "csrf": "~3.0.3",
     "debug": "~2.2.0",
     "debug": "~2.2.0",
     "del": "~2.2.0",
     "del": "~2.2.0",
     "diff": "~2.2.2",
     "diff": "~2.2.2",

+ 1 - 0
resource/css/_wiki.scss

@@ -104,6 +104,7 @@ div.body {
     background: #444;
     background: #444;
     color: #f0f0f0;
     color: #f0f0f0;
     font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
     font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+    word-break: break-word;
   }
   }
 
 
   img {
   img {

+ 72 - 22
resource/js/crowi.js

@@ -322,14 +322,15 @@ $(function() {
     return false;
     return false;
   });
   });
 
 
+  // rename
   $('#renamePage').on('shown.bs.modal', function (e) {
   $('#renamePage').on('shown.bs.modal', function (e) {
     $('#newPageName').focus();
     $('#newPageName').focus();
   });
   });
-  $('#renamePageForm').submit(function(e) {
+  $('#renamePageForm, #unportalize-form').submit(function(e) {
     $.ajax({
     $.ajax({
       type: 'POST',
       type: 'POST',
       url: '/_api/pages.rename',
       url: '/_api/pages.rename',
-      data: $('#renamePageForm').serialize(),
+      data: $(this).serialize(),
       dataType: 'json'
       dataType: 'json'
     }).done(function(res) {
     }).done(function(res) {
       if (!res.ok) {
       if (!res.ok) {
@@ -337,13 +338,12 @@ $(function() {
         $('#newPageNameCheck').addClass('alert-danger');
         $('#newPageNameCheck').addClass('alert-danger');
       } else {
       } else {
         var page = res.page;
         var page = res.page;
-        var path = $('#pagePath').html();
 
 
         $('#newPageNameCheck').removeClass('alert-danger');
         $('#newPageNameCheck').removeClass('alert-danger');
         $('#newPageNameCheck').html('<img src="/images/loading_s.gif"> 移動しました。移動先にジャンプします。');
         $('#newPageNameCheck').html('<img src="/images/loading_s.gif"> 移動しました。移動先にジャンプします。');
 
 
         setTimeout(function() {
         setTimeout(function() {
-          top.location.href = page.path + '?renamed=' + path;
+          top.location.href = page.path + '?renamed=' + pagePath;
         }, 1000);
         }, 1000);
       }
       }
     });
     });
@@ -351,6 +351,44 @@ $(function() {
     return false;
     return false;
   });
   });
 
 
+  // delete
+  $('#delete-page-form').submit(function(e) {
+    $.ajax({
+      type: 'POST',
+      url: '/_api/pages.remove',
+      data: $('#delete-page-form').serialize(),
+      dataType: 'json'
+    }).done(function(res) {
+      if (!res.ok) {
+        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
+        $('#delete-errors').addClass('alert-danger');
+      } else {
+        var page = res.page;
+        top.location.href = page.path;
+      }
+    });
+
+    return false;
+  });
+  $('#revert-delete-page-form').submit(function(e) {
+    $.ajax({
+      type: 'POST',
+      url: '/_api/pages.revertRemove',
+      data: $('#revert-delete-page-form').serialize(),
+      dataType: 'json'
+    }).done(function(res) {
+      if (!res.ok) {
+        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
+        $('#delete-errors').addClass('alert-danger');
+      } else {
+        var page = res.page;
+        top.location.href = page.path;
+      }
+    });
+
+    return false;
+  });
+
   $('#create-portal-button').on('click', function(e) {
   $('#create-portal-button').on('click', function(e) {
     $('.portal').removeClass('hide');
     $('.portal').removeClass('hide');
     $('.content-main').addClass('on-edit');
     $('.content-main').addClass('on-edit');
@@ -633,14 +671,15 @@ $(function() {
 
 
     $bookmarkButton.click(function() {
     $bookmarkButton.click(function() {
       var bookmarked = $bookmarkButton.data('bookmarked');
       var bookmarked = $bookmarkButton.data('bookmarked');
+      var token = $bookmarkButton.data('csrftoken');
       if (!bookmarked) {
       if (!bookmarked) {
-        $.post('/_api/bookmarks.add', {page_id: pageId}, function(res) {
+        $.post('/_api/bookmarks.add', {_csrf: token, page_id: pageId}, function(res) {
           if (res.ok && res.bookmark) {
           if (res.ok && res.bookmark) {
             MarkBookmarked();
             MarkBookmarked();
           }
           }
         });
         });
       } else {
       } else {
-        $.post('/_api/bookmarks.remove', {page_id: pageId}, function(res) {
+        $.post('/_api/bookmarks.remove', {_csrf: token, page_id: pageId}, function(res) {
           if (res.ok) {
           if (res.ok) {
             MarkUnBookmarked();
             MarkUnBookmarked();
           }
           }
@@ -671,14 +710,15 @@ $(function() {
     var $likeCount = $('#like-count');
     var $likeCount = $('#like-count');
     $likeButton.click(function() {
     $likeButton.click(function() {
       var liked = $likeButton.data('liked');
       var liked = $likeButton.data('liked');
+      var token = $likeButton.data('csrftoken');
       if (!liked) {
       if (!liked) {
-        $.post('/_api/likes.add', {page_id: pageId}, function(res) {
+        $.post('/_api/likes.add', {_csrf: token, page_id: pageId}, function(res) {
           if (res.ok) {
           if (res.ok) {
             MarkLiked();
             MarkLiked();
           }
           }
         });
         });
       } else {
       } else {
-        $.post('/_api/likes.remove', {page_id: pageId}, function(res) {
+        $.post('/_api/likes.remove', {_csrf: token, page_id: pageId}, function(res) {
           if (res.ok) {
           if (res.ok) {
             MarkUnLiked();
             MarkUnLiked();
           }
           }
@@ -729,16 +769,18 @@ $(function() {
     }
     }
 
 
     var $seenUserList = $("#seen-user-list");
     var $seenUserList = $("#seen-user-list");
-    var seenUsers = $seenUserList.data('seen-users');
-    var seenUsersArray = seenUsers.split(',');
-    if (seenUsers && seenUsersArray.length > 0 && seenUsersArray.length <= 10) {
-      // FIXME: user data cache
-      $.get('/_api/users.list', {user_ids: seenUsers}, function(res) {
-        // ignore unless response has error
-        if (res.ok) {
-          AddToSeenUser(res.users);
-        }
-      });
+    if ($seenUserList && $seenUserList.length > 0) {
+      var seenUsers = $seenUserList.data('seen-users');
+      var seenUsersArray = seenUsers.split(',');
+      if (seenUsers && seenUsersArray.length > 0 && seenUsersArray.length <= 10) {
+        // FIXME: user data cache
+        $.get('/_api/users.list', {user_ids: seenUsers}, function(res) {
+          // ignore unless response has error
+          if (res.ok) {
+            AddToSeenUser(res.users);
+          }
+        });
+      }
     }
     }
 
 
     function CreateUserLinkWithPicture (user) {
     function CreateUserLinkWithPicture (user) {
@@ -803,6 +845,11 @@ $(function() {
       } else {
       } else {
         var revisionIds = revisionId + ',' + beforeRevisionId;
         var revisionIds = revisionId + ',' + beforeRevisionId;
 
 
+        if ($diffDisplay.data('loaded')) {
+          $diffDisplay.slideToggle();
+          return true;
+        }
+
         $.ajax({
         $.ajax({
           type: 'GET',
           type: 'GET',
           url: '/_api/revisions.list?revision_ids=' + revisionIds,
           url: '/_api/revisions.list?revision_ids=' + revisionIds,
@@ -822,16 +869,19 @@ $(function() {
             $diffDisplay.append($span);
             $diffDisplay.append($span);
           });
           });
 
 
+          $diffDisplay.data('loaded', 1);
           $diffDisplay.slideToggle();
           $diffDisplay.slideToggle();
         });
         });
       }
       }
     });
     });
 
 
     // default open
     // default open
-    $('.diff-view').each(function(i, diffView) {
-      if (i < 2) {
-        $(diffView).click();
-      }
+    $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
+      $('.diff-view').each(function(i, diffView) {
+        if (i < 2) {
+          $(diffView).click();
+        }
+      });
     });
     });
 
 
     // presentation
     // presentation

+ 63 - 1
test/models/page.test.js

@@ -62,7 +62,7 @@ describe('Page', function () {
         },
         },
       ];
       ];
 
 
-      testDBUtil.generateFixture(conn, 'Page', fixture)
+      return testDBUtil.generateFixture(conn, 'Page', fixture)
       .then(function(pages) {
       .then(function(pages) {
         done();
         done();
       });
       });
@@ -93,6 +93,68 @@ describe('Page', function () {
     });
     });
   });
   });
 
 
+  describe('.getDeletedPageName', function() {
+    it('should return trash page name', function() {
+      expect(Page.getDeletedPageName('/hoge')).to.be.equal('/trash/hoge');
+      expect(Page.getDeletedPageName('hoge')).to.be.equal('/trash/hoge');
+    });
+  });
+  describe('.getRevertDeletedPageName', function() {
+    it('should return reverted trash page name', function() {
+      expect(Page.getRevertDeletedPageName('/hoge')).to.be.equal('/hoge');
+      expect(Page.getRevertDeletedPageName('/trash/hoge')).to.be.equal('/hoge');
+      expect(Page.getRevertDeletedPageName('/trash/hoge/trash')).to.be.equal('/hoge/trash');
+    });
+  });
+
+  describe('.isDeletableName', function() {
+    it('should decide deletable or not', function() {
+      expect(Page.isDeletableName('/hoge')).to.be.true;
+      expect(Page.isDeletableName('/user/xxx')).to.be.false;
+      expect(Page.isDeletableName('/user/xxx123')).to.be.false;
+      expect(Page.isDeletableName('/user/xxx/')).to.be.true;
+      expect(Page.isDeletableName('/user/xxx/hoge')).to.be.true;
+    });
+  });
+
+  describe('.isCreatableName', function() {
+    it('should decide creatable or not', function() {
+      expect(Page.isCreatableName('/hoge')).to.be.true;
+
+      // edge cases
+      expect(Page.isCreatableName('/me')).to.be.false;
+      expect(Page.isCreatableName('/me/')).to.be.false;
+      expect(Page.isCreatableName('/me/x')).to.be.false;
+      expect(Page.isCreatableName('/meeting')).to.be.true;
+      expect(Page.isCreatableName('/meeting/x')).to.be.true;
+
+      // under score
+      expect(Page.isCreatableName('/_')).to.be.false;
+      expect(Page.isCreatableName('/_r/x')).to.be.false;
+      expect(Page.isCreatableName('/_api')).to.be.false;
+      expect(Page.isCreatableName('/_apix')).to.be.false;
+      expect(Page.isCreatableName('/_api/x')).to.be.false;
+
+      expect(Page.isCreatableName('/hoge/xx.md')).to.be.false;
+
+
+      var forbidden = ['installer', 'register', 'login', 'logout', 'admin', 'files', 'trash', 'paste', 'comments'];
+      for (var i = 0; i < forbidden.length ; i++) {
+        var pn = forbidden[i];
+        expect(Page.isCreatableName('/' + pn + '')).to.be.false;
+        expect(Page.isCreatableName('/' + pn + '/')).to.be.false;
+        expect(Page.isCreatableName('/' + pn + '/abc')).to.be.false;
+      }
+
+      var forbidden = ['bookmarks', 'comments', 'activities', 'pages', 'recent-create', 'recent-edit'];
+      for (var i = 0; i < forbidden.length ; i++) {
+        var pn = forbidden[i];
+        expect(Page.isCreatableName('/user/aoi/' + pn)).to.be.false;
+        expect(Page.isCreatableName('/user/aoi/x/' + pn)).to.be.true;
+      }
+    });
+  });
+
   describe('.isCreator', function() {
   describe('.isCreator', function() {
     context('with creator', function() {
     context('with creator', function() {
       it('should return true', function(done) {
       it('should return true', function(done) {