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

Merge pull request #35 from crowi/image-upload

Dropable Image Uploader
Sotaro KARASAWA 10 лет назад
Родитель
Сommit
bc7522f547

+ 2 - 1
bower.json

@@ -25,6 +25,7 @@
     "marked": "~0.3.3",
     "reveal.js": "~3.0.0",
     "jquery": "~2.1.3",
-    "highlightjs": "~8.4.0"
+    "highlightjs": "~8.4.0",
+    "inline-attachment": "~2.0.1"
   }
 }

+ 2 - 0
gulpfile.js

@@ -36,6 +36,8 @@ var js = {
   src: [
     'bower_components/jquery/dist/jquery.js',
     'bower_components/bootstrap-sass-official/assets/javascripts/bootstrap.js',
+    'bower_components/inline-attachment/src/inline-attachment.js',
+    'bower_components/inline-attachment/src/jquery.inline-attachment.js',
     'node_modules/socket.io-client/socket.io.js',
     'bower_components/marked/lib/marked.js',
     'bower_components/jquery.cookie/jquery.cookie.js',

+ 83 - 0
lib/models/attachment.js

@@ -0,0 +1,83 @@
+module.exports = function(crowi) {
+  var debug = require('debug')('crowi:models:attachment')
+    , mongoose = require('mongoose')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+    , Promise = require('bluebird')
+  ;
+
+  function generateFileHash (fileName) {
+    var hasher = require('crypto').createHash('md5');
+    hasher.update(fileName);
+
+    return hasher.digest('hex');
+  }
+
+  attachmentSchema = new mongoose.Schema({
+    page: { type: ObjectId, ref: 'Page', index: true },
+    creator: { type: ObjectId, ref: 'User', index: true  },
+    filePath: { type: String, required: true },
+    fileName: { type: String, required: true },
+    originalName: { type: String },
+    fileFormat: { type: String, required: true },
+    fileSize: { type: Number, default: 0 },
+    createdAt: { type: Date, default: Date.now }
+  });
+
+  attachmentSchema.statics.getListByPageId = function(id) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+
+      self
+        .find({page: id})
+        .sort({'updatedAt': 1})
+        .populate('creator')
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (data.length < 1) {
+            return resolve([]);
+          }
+
+          debug(data);
+          return resolve(data);
+        });
+    });
+  };
+
+  attachmentSchema.statics.create = function(pageId, creator, filePath, originalName, fileName, fileFormat, fileSize) {
+    var Attachment = this;
+
+    return new Promise(function(resolve, reject) {
+      var newAttachment = new Attachment();
+
+      newAttachment.page = pageId;
+      newAttachment.creator = creator._id;
+      newAttachment.filePath = filePath;
+      newAttachment.originalName = originalName;
+      newAttachment.fileName = fileName;
+      newAttachment.fileFormat = fileFormat;
+      newAttachment.fileSize = fileSize;
+      newAttachment.createdAt = Date.now();
+
+      newAttachment.save(function(err, data) {
+        if (err) {
+          debug('Error on saving attachment.', err);
+          return reject(err);
+        }
+        debug('Attachment saved.', data);
+        return resolve(data);
+      });
+    });
+  };
+
+  attachmentSchema.statics.createAttachmentFilePath = function (pageId, fileName, fileType) {
+    var ext = '.' + fileName.match(/(.*)(?:\.([^.]+$))/)[2];
+
+    return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
+  };
+
+  return mongoose.model('Attachment', attachmentSchema);
+};

+ 1 - 0
lib/models/index.js

@@ -3,4 +3,5 @@ module.exports = {
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
+  Attachment: require('./attachment'),
 };

+ 24 - 5
lib/models/page.js

@@ -50,8 +50,18 @@ module.exports = function(crowi) {
     return false;
   };
 
+  pageSchema.methods.isCreator = function(userData) {
+    if (this.populated('creator') && this.creator._id.toString() === userData._id.toString()) {
+      return true;
+    } else if (this.creator.toString() === userData._id.toString()) {
+      return true
+    }
+
+    return false;
+  };
+
   pageSchema.methods.isGrantedFor = function(userData) {
-    if (this.isPublic()) {
+    if (this.isPublic() || this.isCreator(userData)) {
       return true;
     }
 
@@ -346,13 +356,18 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.pushRevision = function(pageData, newRevision, user, cb) {
-    pageData.revision = newRevision._id;
-    pageData.updatedAt = Date.now();
-
     newRevision.save(function(err, newRevision) {
+      if (err) {
+        debug('Error on saving revision', err);
+        return cb(err, null);
+      }
+
+      debug('Successfully saved new revision', newRevision);
+      pageData.revision = newRevision._id;
+      pageData.updatedAt = Date.now();
       pageData.save(function(err, data) {
         if (err) {
-          console.log('Error on save page data', err);
+          debug('Error on save page data (after push revision)', err);
           cb(err, null);
           return;
         }
@@ -365,6 +380,7 @@ module.exports = function(crowi) {
     var Page = this
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
+      , grant = options.grant || GRANT_PUBLIC
       , redirectTo = options.redirectTo || null;
 
     this.findOne({path: path}, function(err, pageData) {
@@ -379,6 +395,9 @@ module.exports = function(crowi) {
       newPage.createdAt = Date.now();
       newPage.updatedAt = Date.now();
       newPage.redirectTo = redirectTo;
+      newPage.grant = grant;
+      newPage.grantedUsers = [];
+      newPage.grantedUsers.push(user);
       newPage.save(function (err, newPage) {
 
         var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});

+ 4 - 0
lib/models/user.js

@@ -35,6 +35,10 @@ module.exports = function(crowi) {
     var Config = crowi.model('Config'),
       config = crowi.getConfig();
 
+    if (!config.crowi) {
+      return STATUS_ACTIVE; // is this ok?
+    }
+
     // status decided depends on registrationMode
     switch (config.crowi['security:registrationMode']) {
       case Config.SECURITY_REGISTRATION_MODE_OPEN:

+ 141 - 0
lib/routes/attachment.js

@@ -0,0 +1,141 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routs:attachment')
+    , Attachment = crowi.model('Attachment')
+    , User = crowi.model('User')
+    , Page = crowi.model('Page')
+    , Promise = require('bluebird')
+    , config = crowi.getConfig()
+    , fs = require('fs')
+    , actions = {}
+    , api = {};
+
+  actions.api = api;
+
+  api.list = function(req, res){
+    var id = req.params.pageId;
+
+    Attachment.getListByPageId(id)
+    .then(function(attachments) {
+      res.json({
+        status: true,
+        data: {
+          fileBaseUrl: 'https://' + config.crowi['aws:bucket'] +'.s3.amazonaws.com/', // FIXME: ベタ書きよくない
+          attachments: attachments
+        }
+      });
+    });
+  };
+
+  /**
+   *
+   */
+  api.add = function(req, res){
+    var id = req.params.pageId,
+      path = decodeURIComponent(req.body.path),
+      pageCreated = false,
+      page = {};
+
+    debug('id and path are: ', id, path);
+
+    var fileUploader = require('../util/fileUploader')(crowi, app);
+    var tmpFile = req.files.file || null;
+    debug('Uploaded tmpFile: ', tmpFile);
+    if (!tmpFile) {
+      return res.json({
+        status: false,
+        message: 'File error.'
+      });
+    }
+
+    new Promise(function(resolve, reject) {
+      if (id == 0) {
+        debug('Create page before file upload');
+        Page.create(path, '# '  + path, req.user, {grant: Page.GRANT_OWNER}, function(err, pageData) {
+          if (err) {
+            debug('Page create error', err);
+            return reject(err);
+          }
+          pageCreated = true;
+          return resolve(pageData);
+        });
+      } else {
+        Page.findPageById(id, function(err, pageData){
+          if (err) {
+            debug('Page find error', err);
+            return reject(err);
+          }
+          return resolve(pageData);
+        });
+      }
+    }).then(function(pageData) {
+      page = pageData;
+      id = pageData._id;
+
+      var tmpPath = tmpFile.path,
+        originalName = tmpFile.originalname,
+        fileName = tmpFile.name,
+        fileType = tmpFile.mimetype,
+        fileSize = tmpFile.size,
+        filePath = Attachment.createAttachmentFilePath(id, fileName, fileType);
+
+      fileUploader.uploadFile(
+        filePath,
+        fileType,
+        fs.createReadStream(tmpPath, {
+          flags: 'r',
+          encoding: null,
+          fd: null,
+          mode: '0666',
+          autoClose: true
+        }),
+        {}
+      ).then(function(data) {
+        debug('Uploaded data is: ', data);
+
+        // TODO size
+        Attachment.create(id, req.user, filePath, originalName, fileName, fileType, fileSize)
+        .then(function(data) {
+          var imageUrl = fileUploader.generateS3FileUrl(data.filePath);
+          return res.json({
+            status: true,
+            filename: imageUrl,
+            attachment: data,
+            page: page,
+            pageCreated: pageCreated,
+            message: 'Successfully uploaded.',
+          });
+        }, function (err) {
+          debug('Error on saving attachment data', err);
+
+          // @TODO
+          // Remove from S3
+          return res.json({
+            status: false,
+            message: '',
+          });
+        }).finally(function() {
+          fs.unlink(tmpPath, function (err) {
+            if (err) {
+              debug('Error while deleting tmp file.');
+            }
+          });
+        });
+      }, function(err) {
+        debug('Upload error to S3.', err);
+
+        return res.json({
+          status: false,
+          message: 'Error while uploading.',
+        });
+      });
+    });
+  };
+
+  api.remove = function(req, res){
+    var id = req.params.id;
+  };
+
+  return actions;
+};

+ 22 - 18
lib/routes/index.js

@@ -8,6 +8,7 @@ module.exports = function(crowi, app) {
     , admin     = require('./admin')(crowi, app)
     , installer = require('./installer')(crowi, app)
     , user      = require('./user')(crowi, app)
+    , attachment= require('./attachment')(crowi, app)
     , loginRequired = middleware.loginRequired
     ;
 
@@ -49,25 +50,28 @@ module.exports = function(crowi, app) {
   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.get('/me'                      , loginRequired(crowi, app) , me.index);
-  app.get('/me/password'             , loginRequired(crowi, app) , me.password);
-  app.post('/me'                     , form.me.user               , loginRequired(crowi, app) , me.index);
-  app.post('/me/password'            , form.me.password           , loginRequired(crowi, app) , me.password);
-  app.post('/me/picture/delete'      , loginRequired(crowi, app) , me.deletePicture);
-  app.post('/me/auth/facebook'       , loginRequired(crowi, app) , me.authFacebook);
-  app.post('/me/auth/google'         , loginRequired(crowi, app) , me.authGoogle);
-  app.get('/me/auth/google/callback' , loginRequired(crowi, app) , me.authGoogleCallback);
+  app.get('/me'                       , loginRequired(crowi, app) , me.index);
+  app.get('/me/password'              , loginRequired(crowi, app) , me.password);
+  app.post('/me'                      , form.me.user              , loginRequired(crowi, app) , me.index);
+  app.post('/me/password'             , form.me.password          , loginRequired(crowi, app) , me.password);
+  app.post('/me/picture/delete'       , loginRequired(crowi, app) , me.deletePicture);
+  app.post('/me/auth/facebook'        , loginRequired(crowi, app) , me.authFacebook);
+  app.post('/me/auth/google'          , loginRequired(crowi, app) , me.authGoogle);
+  app.get( '/me/auth/google/callback' , loginRequired(crowi, app) , me.authGoogleCallback);
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired(crowi, app) , page.api.redirector);
-  app.get('/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app) , page.api.redirector); // alias
-  app.get('/_api/check_username'     , user.api.checkUsername);
-  app.post('/_api/me/picture/upload' , loginRequired(crowi, app) , me.api.uploadPicture);
-  app.get('/_api/user/bookmarks'     , loginRequired(crowi, app) , user.api.bookmarks);
-  app.post('/_api/page_rename/*'     , loginRequired(crowi, app) , page.api.rename);
-  app.post('/_api/page/:id/like'     , loginRequired(crowi, app) , page.api.like);
-  app.post('/_api/page/:id/unlike'   , loginRequired(crowi, app) , page.api.unlike);
-  app.get('/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.isBookmarked);
-  app.post('/_api/page/:id/bookmark' , loginRequired(crowi, app) , page.api.bookmark);
+  app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app) , page.api.redirector);
+  app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app) , page.api.redirector); // alias
+  app.get( '/_api/check_username'     , user.api.checkUsername);
+  app.post('/_api/me/picture/upload'  , loginRequired(crowi, app) , me.api.uploadPicture);
+  app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app) , user.api.bookmarks);
+  app.post('/_api/page_rename/*'      , loginRequired(crowi, app) , page.api.rename);
+  app.get( '/_api/attachment/page/:pageId', loginRequired(crowi, app) , attachment.api.list);
+  app.post('/_api/attachment/page/:pageId', loginRequired(crowi, app) , attachment.api.add);
+  app.post('/_api/attachment/:id/remove',loginRequired(crowi, app), attachment.api.remove);
+  app.post('/_api/page/:id/like'      , loginRequired(crowi, app) , page.api.like);
+  app.post('/_api/page/:id/unlike'    , loginRequired(crowi, app) , page.api.unlike);
+  app.get( '/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.isBookmarked);
+  app.post('/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.bookmark);
   //app.get('/_api/page/*'           , user.useUserData()         , page.api.get);
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);

+ 1 - 1
lib/routes/me.js

@@ -65,7 +65,7 @@ module.exports = function(crowi, app) {
             'message': 'Error while uploading to ',
           });
         }
-        var imageUrl = fileUploader.generateS3FillUrl(filePath);
+        var imageUrl = fileUploader.generateS3FileUrl(filePath);
         req.user.updateImage(imageUrl, function(err, data) {
           fs.unlink(tmpPath, function (err) {
             // エラー自体は無視

+ 2 - 1
lib/routes/page.js

@@ -107,6 +107,7 @@ module.exports = function(crowi, app) {
         res.redirect(encodeURI(path));
         return ;
       }
+      debug('Page found', pageData);
 
       if (err == Page.PAGE_GRANT_ERROR) {
         debug('PAGE_GRANT_ERROR');
@@ -168,7 +169,7 @@ module.exports = function(crowi, app) {
         var newRevision = Revision.prepareRevision(pageData, body, req.user);
         Page.pushRevision(pageData, newRevision, req.user, cb);
       } else {
-        Page.create(path, body, req.user, {format: format}, cb);
+        Page.create(path, body, req.user, {format: format, grant: grant}, cb);
       }
     });
   };

+ 26 - 4
lib/util/fileUploader.js

@@ -8,6 +8,7 @@ module.exports = function(crowi, app) {
 
   var aws = require('aws-sdk')
     , debug = require('debug')('crowi:lib:fileUploader')
+    , Promise = require('bluebird')
     , config = crowi.getConfig()
     , lib = {}
     ;
@@ -22,12 +23,27 @@ module.exports = function(crowi, app) {
     };
   }
 
+  function isUploadable(awsConfig) {
+    if (!awsConfig.accessKeyId ||
+        !awsConfig.secretAccessKey ||
+        !awsConfig.region ||
+        !awsConfig.bucket) {
+      return false;
+    }
+
+    return true;
+  }
+
   // lib.deleteFile = function(filePath, callback) {
   //   // TODO 実装する
   // };
+  //
 
-  lib.uploadFile = function(filePath, contentType, fileStream, options, callback) {
+  lib.uploadFile = function(filePath, contentType, fileStream, options) {
     var awsConfig = getAwsConfig();
+    if (!isUploadable(awsConfig)) {
+      return new Promise.reject(new Error('AWS is not configured.'));
+    }
 
     aws.config.update({
       accessKeyId: awsConfig.accessKeyId,
@@ -42,12 +58,18 @@ module.exports = function(crowi, app) {
     params.Body = fileStream;
     params.ACL = 'public-read';
 
-    s3.putObject(params, function(err, data) {
-      callback(err, data);
+    return new Promise(function(resolve, reject) {
+      s3.putObject(params, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
     });
   };
 
-  lib.generateS3FillUrl = function(filePath) {
+  lib.generateS3FileUrl = function(filePath) {
     var awsConfig = getAwsConfig();
     var url = 'https://' + awsConfig.bucket +'.s3.amazonaws.com/' + filePath;
 

+ 96 - 10
lib/views/_form.html

@@ -9,30 +9,37 @@
 </div>
 {% endif %}
 <div id="form-box" class="row">
-  <div class="col-md-6">
-    <form action="{{ path }}/edit" method="post" class="">
-      <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
+  <form action="{{ path }}/edit" id="page-form" method="post" class="col-md-6">
+    <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
 
-      <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
-      <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
-      <div class="form-submit-group form-group form-inline">
+    <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
+    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
+    <div class="form-submit-group form-group form-inline">
+      {#<button class="btn btn-default">
+        <i class="fa fa-file-text"></i>
+        ファイルを追加 ...
+      </button>#}
+
+      <div class="pull-right form-inline">
         <select name="pageForm[grant]" class="form-control">
           {% for grantId, grantLabel in consts.pageGrants %}
           <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ grantLabel }}</option>
           {% endfor %}
         </select>
-
         <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
       </div>
-    </form>
-  </div>
-  <div class="col-md-6">
+    </div>
+  </form>
+  <div class="col-md-6 hidden-sm hidden-xs">
     <div id="preview-body" class="wiki preview-body">
     </div>
   </div>
+  <div class="file-module hidden">
+  </div>
   <script type="text/javascript">
   $(function() {
     // preview watch
+    var originalContent = $('#form-body').val();
     var prevContent = "";
     var watchTimer = setInterval(function() {
       var content = $('#form-body').val();
@@ -66,7 +73,86 @@
         self.blur();
       }
     });
+
+    var unbindInlineAttachment = function($form) {
+      $form.unbind('.inlineattach');
+    };
+    var bindInlineAttachment = function($form, attachmentOption) {
+      var $this = $form;
+      var editor = createEditorInstance($form);
+      var inlineattach = new inlineAttachment(attachmentOption, editor);
+      $form.bind({
+        'paste.inlineattach': function(e) {
+          inlineattach.onPaste(e.originalEvent);
+        },
+        'drop.inlineattach': function(e) {
+          e.stopPropagation();
+          e.preventDefault();
+          inlineattach.onDrop(e.originalEvent);
+        },
+        'dragenter.inlineattach dragover.inlineattach': function(e) {
+          e.stopPropagation();
+          e.preventDefault();
+        }
+      });
+    };
+    var createEditorInstance = function($form) {
+      var $this = $form;
+
+      return {
+        getValue: function() {
+          return $this.val();
+        },
+        insertValue: function(val) {
+          inlineAttachment.util.insertTextAtCursor($this[0], val);
+        },
+        setValue: function(val) {
+          $this.val(val);
+        }
+      };
+    };
+
+    var $inputForm = $('textarea#form-body');
+    if ($inputForm.length > 0) {
+      var pageId = $('#content-main').data('page-id') || 0;
+      var attachmentOption = {
+        uploadUrl: '/_api/attachment/page/' + pageId,
+        extraParams: {
+          path: location.pathname
+        },
+        progressText: '(Uploading file...)',
+        urlText: "\n![file]({filename})\n"
+      };
+
+      attachmentOption.onFileUploadResponse = function(res) {
+        var result = JSON.parse(res.response);
+
+        if (result.status && result.pageCreated) {
+          var page = result.page,
+            pageId = page._id;
+
+          $('#content-main').data('page-id', page._id);
+          $('#page-form [name="pageForm[currentRevision]"]').val(page.revision)
+
+          unbindInlineAttachment($inputForm);
+
+          attachmentOption.uploadUrl = '/_api/attachment/page/' + pageId,
+          bindInlineAttachment($inputForm, attachmentOption);
+        }
+        return true;
+      };
+
+      bindInlineAttachment($inputForm, attachmentOption);
+    }
+
+    $('textarea#form-body').on('dragenter dragover', function() {
+        $(this).addClass('dragover');
+      });
+    $('textarea#form-body').on('drop dragleave dragend', function() {
+        $(this).removeClass('dragover');
+      });
   });
 
+
   </script>
 </div>

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

@@ -141,7 +141,7 @@
 {% endblock %} {# layout_sidebar #}
 
 {% block layout_main %}
-<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %}" ng-controller="WikiPageController">
+<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %}">
   {% if page && page.grant != 1 %}
   <p class="page-grant">
     <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} (このページの閲覧は制限されています)

+ 29 - 1
lib/views/page.html

@@ -13,7 +13,7 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main {% if not page %}on-edit{% endif %}">
+<div id="content-main" class="content-main {% if not page %}on-edit{% endif %}" data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}">
 
   {% if not page %}
   <ul class="nav nav-tabs hidden-print">
@@ -143,6 +143,34 @@
 {% endblock %}
 
 {% block content_footer %}
+
+<div class="page-attachments">
+  <p>Attachments</p>
+  <ul>
+  </ul>
+</div>
+<script>
+  (function() {
+    var pageId = $('#content-main').data('page-id');
+    var $pageAttachmentList = $('.page-attachments ul');
+    if (pageId) {
+      $.get('/_api/attachment/page/' + pageId, function(res) {
+        var attachments = res.data.attachments;
+        var urlBase = res.data.fileBaseUrl;
+        if (attachments.length > 0) {
+          $.each(attachments, function(i, file) {
+            console.log(file);
+            $pageAttachmentList.append(
+            '<li><a href="' + urlBase + file.filePath + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
+            );
+          })
+        } else {
+          $('.page-attachments').remove();
+        }
+      });
+    }
+  })();
+</script>
 <footer>
   {% if not page %}
   {% else %}

+ 1 - 1
package.json

@@ -57,7 +57,7 @@
     "jshint-stylish": "^2.0.0",
     "method-override": "~2.3.1",
     "mongoose": "~3.8.0",
-    "mongoose-paginate": "~3.1.0",
+    "mongoose-paginate": "=3.1.3",
     "morgan": "~1.5.1",
     "multer": "~0.1.8",
     "nodemailer": "~1.2.2",

+ 0 - 18
resource/css/_form.scss

@@ -1,8 +1,4 @@
 
-.form-full {
-  width: 99%;
-}
-
 textarea {
   font-family: menlo, monaco, consolas, monospace;
   line-height: 1.1em;
@@ -18,17 +14,3 @@ input::-webkit-input-placeholder {
 input:-moz-placeholder {
   color: #ccc;
 }
-
-
-.form-maximized {
-  position: absolute;
-  background: #fff;
-  top: 0;
-  left: 0;
-  margin: 0;
-  padding: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 100;
-  -webkit-transition: opacity 1s linear;
-}

+ 82 - 16
resource/css/_layout.scss

@@ -305,6 +305,21 @@
       }
     }
 
+    .page-attachments {
+      background: #f0f0f0;
+      padding: 10px;
+      font-size: 0.9em;
+      color: #888;
+      margin: 10px 0;
+      border-radius: 5px;
+      p {
+        font-weight: bold;
+      }
+
+      ul {
+      }
+    }
+
     .footer { // {{{
       position: fixed;
       width: 100%;
@@ -388,16 +403,24 @@
     left: 0;
     height: 100%;
     width: 100%;
-    padding: 16px;
 
     .nav {
       margin-top: 8px;
-      max-height: 8%;
+      height: 40px;
     }
 
     .tab-content {
-      margin-top: 1%;
-      height: 83%;
+      .alert-info {
+        display: none;
+      }
+
+      top: 48px;
+      bottom: 58px;
+      padding: 0 12px;
+      position: absolute;
+      left: 0;
+      right: 0;
+      margin-top: 4px;
 
       .edit-form {
         height: 100%;
@@ -405,19 +428,48 @@
           height: 100%;
           .col-md-6 {
             height: 100%;
-
-            form {
-              height: 100%;
-              textarea {
-                height: 100%;
-              }
+          }
+          form {
+            padding: 0;
+            border-right: solid 1px #ccc;
+            &::after {
+              position: absolute;
+              top: 0;
+              right: 15px;
+              font-size: 10px;
+              font-weight: 700;
+              color: #959595;
+              text-transform: uppercase;
+              letter-spacing: 1px;
+              content: "Input Content ...";
             }
+          }
+          textarea {
+            height: 100%;
+            padding-top: 18px;
+            border: none;
+            box-shadow: none;
 
-            .preview-body {
-              height: 100%;
-              padding-top: 5px;
-              padding-bottom: 5px;
-              overflow: scroll;
+            &.dragover {
+              border: dashed 6px #ccc;
+              padding: 12px 6px 0px;
+            }
+          }
+          .preview-body {
+            height: 100%;
+            padding-top: 18px;
+            overflow: scroll;
+
+            &::after {
+              position: absolute;
+              top: 0;
+              right: 15px;
+              font-size: 10px;
+              font-weight: 700;
+              color: #959595;
+              text-transform: uppercase;
+              letter-spacing: 1px;
+              content: "Preview";
             }
           }
         }
@@ -425,12 +477,14 @@
     }
 
     .form-group.form-submit-group {
+
       position: fixed;
+      z-index: 1054;
       bottom: 0;
       width: 100%;
       left: 0;
       padding: 8px;
-      max-height: 8%;
+      height: 50px;
       background: rgba(255,255,255,.8);
       border-top: solid 1px #ccc;
       margin-bottom: 0;
@@ -524,6 +578,18 @@
 
 } // }}}
 
+@media (max-width: $screen-sm-max) { // {{{ less than tablet size
+
+  .content-main.on-edit {
+    .form-group.form-submit-group {
+      select.form-control {
+        display: inline-block;
+        width: auto;
+      }
+    }
+  }
+
+} // }}}
 
 @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { // {{{ tablet size
   .crowi.main-container { // {{{

+ 1 - 1
resource/css/_wiki.scss

@@ -107,7 +107,7 @@ div.body {
   }
 
   img {
-    margin: 5px;
+    margin: 5px 0;
     box-shadow: 0 0 12px 0px #999;
     border: solid 1px #999;
     max-width: 100%;

+ 25 - 25
test/bootstrap.js

@@ -2,44 +2,44 @@
 
 var express = require('express')
   , async = require('async')
-  , mongoose= require('mongoose')
   , ROOT_DIR = __dirname + '/..'
   , MODEL_DIR = __dirname + '/../lib/models'
-  , mongoUri
+  , Promise = require('bluebird')
   , testDBUtil
   ;
 
-mongoUri = process.env.MONGOLAB_URI || process.env.MONGOHQ_URL || process.env.MONGO_URI || null;
-
-
 testDBUtil = {
-  generateFixture: function (conn, model, fixture, cb) {
+  generateFixture: function (conn, model, fixture) {
+    if (conn.readyState == 0) {
+      return Promise.reject();
+    }
     var m = conn.model(model);
-    async.each(fixture, function(data, next) {
-      var newDoc = new m;
 
-      Object.keys(data).forEach(function(k) {
-        newDoc[k] = data[k];
+    return new Promise(function(resolve, reject) {
+      var createdModels = [];
+      fixture.reduce(function(promise, entity) {
+        return promise.then(function() {
+          var newDoc = new m;
+
+          Object.keys(entity).forEach(function(k) {
+            newDoc[k] = entity[k];
+          });
+          return new Promise(function(r, rj) {
+            newDoc.save(function(err, data) {
+
+              createdModels.push(data);
+              return r();
+            });
+          });
+        });
+      }, Promise.resolve()).then(function() {
+        resolve(createdModels);
       });
-      newDoc.save(next);
-
-    }, function(err) {
-      cb();
     });
-  },
-  cleanUpDb: function (conn, model, cb) {
-    if (!model) {
-      return cb(null, null);
-    }
-
-    var m = conn.model(model);
-    m.remove({}, cb);
-  },
+  }
 };
 
 global.express = express;
-global.mongoose = mongoose;
-global.mongoUri = mongoUri;
 global.ROOT_DIR = ROOT_DIR;
 global.MODEL_DIR = MODEL_DIR;
 global.testDBUtil = testDBUtil;

+ 7 - 11
test/crowi/crowi.test.js

@@ -43,17 +43,13 @@ describe('Test for Crowi application context', function () {
       // set
       var p = crowi.setupDatabase()
       expect(p).to.instanceof(Promise);
-      if (mongoUri) {
-        p.then(function() {
-          expect(mongoose.connection.readyState).to.equals(1);
-          done();
-        });
-      } else {
-        p.catch(function() {
-          expect(mongoose.connection.readyState).to.equals(1);
-          done();
-        });
-      }
+      p.then(function() {
+        expect(mongoose.connection.readyState).to.equals(1);
+        done();
+      }).catch(function() {
+        expect(mongoose.connection.readyState).to.equals(1);
+        done();
+      });
     });
   });
 });

+ 19 - 34
test/models/config.test.js

@@ -2,46 +2,31 @@ var chai = require('chai')
   , expect = chai.expect
   , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
-  , proxyquire = require('proxyquire')
+  , Promise = require('bluebird')
+  , utils = require('../utils.js')
   ;
 chai.use(sinonChai);
 
 describe('Config model test', function () {
-  var conn
-    , crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env)
-    , Config = proxyquire(MODEL_DIR + '/config.js', {mongoose: mongoose})(crowi)
-    ;
+  var Page = utils.models.Page,
+    Config = utils.models.Config,
+    User   = utils.models.User,
+    conn = utils.mongoose.connection;
 
   before(function (done) {
-    if (mongoUri) {
-      // 基本的に mongoUri がセットされてたら、そのURIにはつながる前提
-      conn = mongoose.createConnection(mongoUri, function(err) {
-        if (err) {
-          done(); // ここで skip したいなあ
-        }
-
-        Config = conn.model('Config');
-        var fixture = [
-          {ns: 'crowi', key: 'test:test', value: JSON.stringify('crowi test value')},
-          {ns: 'crowi', key: 'test:test2', value: JSON.stringify(11111)},
-          {ns: 'crowi', key: 'test:test3', value: JSON.stringify([1, 2, 3, 4, 5])},
-          {ns: 'plugin', key: 'other:config', value: JSON.stringify('this is data')},
-        ];
-
-        testDBUtil.generateFixture(conn, 'Config', fixture, done);
-      });
-    }
-  });
-
-  beforeEach(function () {
-  });
-
-  after(function (done) {
-    if (mongoUri) {
-      return testDBUtil.cleanUpDb(conn, 'Config', function(err, doc) {
-        return conn.close(done);
-      });
-    }
+    var fixture = [
+      {ns: 'crowi', key: 'test:test', value: JSON.stringify('crowi test value')},
+      {ns: 'crowi', key: 'test:test2', value: JSON.stringify(11111)},
+      {ns: 'crowi', key: 'test:test3', value: JSON.stringify([1, 2, 3, 4, 5])},
+      {ns: 'plugin', key: 'other:config', value: JSON.stringify('this is data')},
+    ];
+
+    testDBUtil.generateFixture(conn, 'Config', fixture)
+    .then(function(configs) {
+      done();
+    }).catch(function() {
+      done(new Error('Skip this test.'));
+    });
   });
 
   describe('.CONSTANTS', function () {

+ 65 - 44
test/models/page.test.js

@@ -2,65 +2,64 @@ var chai = require('chai')
   , expect = chai.expect
   , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
-  , proxyquire = require('proxyquire')
+  , Promise = require('bluebird')
+  , utils = require('../utils.js')
   ;
 chai.use(sinonChai);
 
 describe('Page', function () {
-  var conn
-    , crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env)
-    , Page = proxyquire(MODEL_DIR + '/page.js', {mongoose: mongoose})(crowi)
-    , User = proxyquire(MODEL_DIR + '/user.js', {mongoose: mongoose})(crowi)
-    ;
-
-  if (!mongoUri) {
-    return;
-  }
+  var Page = utils.models.Page,
+    User   = utils.models.User,
+    conn   = utils.mongoose.connection;
 
   before(function (done) {
-    conn = mongoose.createConnection(mongoUri, function(err) {
-      if (err) {
-        done();
-      }
+    Promise.resolve().then(function() {
+      var userFixture = [
+        {name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com'},
+        {name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com'}
+      ];
 
-      Page = conn.model('Page');
-      User = conn.model('User');
+      return testDBUtil.generateFixture(conn, 'User', userFixture);
+    }).then(function(testUsers) {
+      var testUser0 = testUsers[0];
 
       var fixture = [
         {
           path: '/user/anonymous/memo',
           grant: Page.GRANT_RESTRICTED,
-          grantedUsers: []
+          grantedUsers: [testUser0],
+          creator: testUser0
         },
         {
           path: '/grant/public',
-          grant: Page.GRANT_PUBLIC
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [testUser0],
+          creator: testUser0
         },
         {
           path: '/grant/restricted',
-          grant: Page.GRANT_RESTRICTED
+          grant: Page.GRANT_RESTRICTED,
+          grantedUsers: [testUser0],
+          creator: testUser0
         },
         {
           path: '/grant/specified',
-          grant: Page.GRANT_SPECIFIED
+          grant: Page.GRANT_SPECIFIED,
+          grantedUsers: [testUser0],
+          creator: testUser0
         },
         {
           path: '/grant/owner',
-          grant: Page.GRANT_OWNER
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser0],
+          creator: testUser0
         }
       ];
-      var userFixture = [
-        {userId: 'anonymous', email: 'anonymous@gmail.com'}
-      ];
-
-      testDBUtil.generateFixture(conn, 'Page', fixture, function() {});
-      testDBUtil.generateFixture(conn, 'User', userFixture, done);
-    });
-  });
 
-  after(function (done) {
-    return testDBUtil.cleanUpDb(conn, 'Page', function(err, doc) {
-      return conn.close(done);
+      testDBUtil.generateFixture(conn, 'Page', fixture)
+      .then(function(pages) {
+        done();
+      });
     });
   });
 
@@ -88,23 +87,45 @@ describe('Page', function () {
     });
   });
 
+  describe('.isCreator', function() {
+    context('with creator', function() {
+      it('should return true', function(done) {
+        User.findOne({email: 'anonymous0@example.com'}, function(err, user) {
+          if (err) { done(err); }
+
+          Page.findOne({path: '/user/anonymous/memo'}, function(err, page) {
+            expect(page.isCreator(user)).to.be.equal(true);
+            done();
+          })
+        });
+      });
+    });
+
+    context('with non-creator', function() {
+      it('should return false', function(done) {
+        User.findOne({email: 'anonymous1@example.com'}, function(err, user) {
+          if (err) { done(err); }
+
+          Page.findOne({path: '/user/anonymous/memo'}, function(err, page) {
+            expect(page.isCreator(user)).to.be.equal(false);
+            done();
+          })
+        });
+      });
+    });
+  });
+
   describe('.isGrantedFor', function() {
     context('with a granted user', function() {
       it('should return true', function(done) {
-        User.find({userId: 'anonymous'}, function(err, user) {
+        User.findOne({email: 'anonymous0@example.com'}, function(err, user) {
           if (err) { done(err); }
 
           Page.findOne({path: '/user/anonymous/memo'}, function(err, page) {
             if (err) { done(err); }
 
-            page.grantedUsers.push(user.id);
-
-            page.save(function(err, newPage) {
-              if (err) { done(err); }
-
-              expect(newPage.isGrantedFor(user)).to.be.equal(true);
-              done();
-            });
+            expect(page.isGrantedFor(user)).to.be.equal(true);
+            done();
           });
         });
       });
@@ -112,10 +133,10 @@ describe('Page', function () {
 
     context('with a public page', function() {
       it('should return true', function(done) {
-        User.find({userId: 'anonymous'}, function(err, user) {
+        User.findOne({email: 'anonymous1@example.com'}, function(err, user) {
           if (err) { done(err); }
 
-          Page.findOne({path: '/user/anonymous/memo'}, function(err, page) {
+          Page.findOne({path: '/grant/public'}, function(err, page) {
             if (err) { done(err); }
 
             expect(page.isGrantedFor(user)).to.be.equal(true);
@@ -127,7 +148,7 @@ describe('Page', function () {
 
     context('with a restricted page and an user who has no grant', function() {
       it('should return false', function(done) {
-        User.find({userId: 'anonymous'}, function(err, user) {
+        User.findOne({email: 'anonymous1@example.com'}, function(err, user) {
           if (err) { done(err); }
 
           Page.findOne({path: '/grant/restricted'}, function(err, page) {

+ 34 - 0
test/models/user.test.js

@@ -0,0 +1,34 @@
+var chai = require('chai')
+  , expect = chai.expect
+  , sinon = require('sinon')
+  , sinonChai = require('sinon-chai')
+  , Promise = require('bluebird')
+  , utils = require('../utils.js')
+  ;
+chai.use(sinonChai);
+
+describe('User', function () {
+  var Page = utils.models.Page,
+    User   = utils.models.User,
+    conn   = utils.mongoose.connection;
+
+  describe('Create and Find.', function () {
+    context('The user', function() {
+      it('should created', function(done) {
+        User.createUserByEmailAndPassword('Aoi Miyazaki', 'aoi', 'aoi@example.com', 'hogefuga11', function (err, userData) {
+          expect(err).to.be.null;
+          expect(userData).to.instanceof(User);
+          done();
+        });
+      });
+
+      it('should be found by findUserByUsername', function(done) {
+        User.findUserByUsername('aoi', function (err, userData) {
+          expect(err).to.be.null;
+          expect(userData).to.instanceof(User);
+          done();
+        });
+      });
+    });
+  });
+});

+ 53 - 0
test/utils.js

@@ -0,0 +1,53 @@
+'use strict';
+
+var mongoUri = process.env.MONGOLAB_URI || process.env.MONGOHQ_URL || process.env.MONGO_URI || null
+  , mongoose= require('mongoose')
+  , models = {}
+  , crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env)
+  ;
+
+
+before('Create database connection and clean up', function (done) {
+  if (!mongoUri) {
+    return done();
+  }
+
+  mongoose.connect(mongoUri);
+
+  function clearDB() {
+    for (var i in mongoose.connection.collections) {
+      mongoose.connection.collections[i].remove(function() {});
+    }
+    return done();
+  }
+
+  if (mongoose.connection.readyState === 0) {
+    mongoose.connect(mongoUri, function (err) {
+      if (err) {
+        throw err;
+      }
+      return clearDB();
+    });
+  } else {
+    return clearDB();
+  }
+});
+
+after('Close database connection', function (done) {
+  if (!mongoUri) {
+    return done();
+  }
+
+  mongoose.disconnect();
+  return done();
+});
+
+
+models.Page   = require(MODEL_DIR + '/page.js')(crowi);
+models.User   = require(MODEL_DIR + '/user.js')(crowi);
+models.Config = require(MODEL_DIR + '/config.js')(crowi);
+
+module.exports = {
+  models: models,
+  mongoose: mongoose,
+}