Browse Source

Implement file delete

- Page attachment reactize
Sotaro KARASAWA 9 years ago
parent
commit
7ccedd59d9

+ 21 - 1
lib/models/attachment.js

@@ -127,7 +127,27 @@ module.exports = function(crowi) {
     // TODO
     var forceUpdate = forceUpdate || false;
 
-    return fileUploader.findDeliveryFile(attachment);
+    return fileUploader.findDeliveryFile(attachment._id, attachment.filePath);
+  };
+
+  attachmentSchema.statics.removeAttachment = function(attachment) {
+    const Attachment = this;
+    const filePath = attachment.filePath;
+
+    return new Promise((resolve, reject) => {
+      Attachment.remove({_id: attachment._id}, (err, data) => {
+        if (err) {
+          return reject(err);
+        }
+
+        fileUploader.deleteFile(attachment._id, filePath)
+        .then(data => {
+          resolve(data); // this may null
+        }).catch(err => {
+          reject(err);
+        });
+      });
+    });
   };
 
   return mongoose.model('Attachment', attachmentSchema);

+ 24 - 1
lib/routes/attachment.js

@@ -173,8 +173,31 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {post} /attachments.remove Remove attachments
+   * @apiName RemoveAttachments
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} attachment_id
+   */
   api.remove = function(req, res){
-    var id = req.params.id;
+    const id = req.body.attachment_id;
+
+    Attachment.findById(id)
+    .then(function(data) {
+      const attachment = data;
+
+      Attachment.removeAttachment(attachment)
+      .then(data => {
+        debug('removeAttachment', data);
+        return res.json(ApiResponse.success({}));
+      }).catch(err => {
+        return res.status(500).json(ApiResponse.error('Error while deleting file'));
+      });
+    }).catch(err => {
+      debug('Error', err);
+      return res.status(404);
+    });
   };
 
   return actions;

+ 3 - 5
lib/views/page.html

@@ -61,6 +61,7 @@
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  data-csrftoken="{{ csrf() }}"
   >
 
   {% if not page %}
@@ -189,13 +190,10 @@
 
 
 {% if page %}
-<div class="page-attachments meta">
-  <p>Attachments</p>
-  <ul>
-  </ul>
+<div class="page-attachments meta" id="page-attachment">
 </div>
 
-<p class="meta">
+<p class="page-meta meta">
   Path: <span id="pagePath">{{ page.path }}</span><br>
   {# for BC #}
   {% if page.lastUpdateUser %}

+ 63 - 20
local_modules/crowi-fileupload-aws/index.js

@@ -4,9 +4,9 @@ module.exports = function(crowi) {
   'use strict';
 
   var aws = require('aws-sdk')
+    , fs = require('fs')
+    , path = require('path')
     , debug = require('debug')('crowi:lib:fileUploaderAws')
-    , Config = crowi.model('Config')
-    , config = crowi.getConfig()
     , lib = {}
     , getAwsConfig = function() {
         var config = crowi.getConfig();
@@ -18,17 +18,13 @@ module.exports = function(crowi) {
         };
       };
 
-  lib.deleteFile = function(filePath) {
-    return new Promise(function(resolve, reject) {
-      debug('Unsupported file deletion.');
-      resolve('TODO: ...');
-    });
-  };
+  function S3Factory() {
+    const awsConfig = getAwsConfig();
+    const Config = crowi.model('Config');
+    const config = crowi.getConfig();
 
-  lib.uploadFile = function(filePath, contentType, fileStream, options) {
-    var awsConfig = getAwsConfig();
     if (!Config.isUploadable(config)) {
-      return Promise.reject(new Error('AWS is not configured.'));
+      throw new Error('AWS is not configured.');
     }
 
     aws.config.update({
@@ -36,7 +32,37 @@ module.exports = function(crowi) {
       secretAccessKey: awsConfig.secretAccessKey,
       region: awsConfig.region
     });
-    var s3 = new aws.S3();
+
+    return new aws.S3();
+  }
+
+  lib.deleteFile = function(fileId, filePath) {
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
+    };
+
+    return new Promise((resolve, reject) => {
+      s3.deleteObject(params, (err, data) => {
+        if (err) {
+          debug('Failed to delete object from s3', err);
+          return reject(err);
+        }
+
+        // asynclonousely delete cache
+        lib.clearCache(fileId);
+
+        resolve(data);
+      });
+    });
+  };
+
+  lib.uploadFile = function(filePath, contentType, fileStream, options) {
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
 
     var params = {Bucket: awsConfig.bucket};
     params.ContentType = contentType;
@@ -62,8 +88,8 @@ module.exports = function(crowi) {
     return url;
   };
 
-  lib.findDeliveryFile = function (attachment) {
-    var cacheFile = lib.createCacheFileName(attachment);
+  lib.findDeliveryFile = function (fileId, filePath) {
+    var cacheFile = lib.createCacheFileName(fileId);
 
     return new Promise((resolve, reject) => {
       debug('find delivery file', cacheFile);
@@ -71,11 +97,10 @@ module.exports = function(crowi) {
         return resolve(cacheFile);
       }
 
-      var fs = require('fs');
       var loader = require('https');
 
       var fileStream = fs.createWriteStream(cacheFile);
-      var fileUrl = lib.generateUrl(attachment.filePath);
+      var fileUrl = lib.generateUrl(filePath);
       debug('Load attachement file into local cache file', fileUrl, cacheFile);
       var request = loader.get(fileUrl, function(response) {
         response.pipe(fileStream, { end: false });
@@ -87,15 +112,33 @@ module.exports = function(crowi) {
     });
   };
 
+  lib.clearCache = function(fileId) {
+    const cacheFile = lib.createCacheFileName(fileId);
+
+    (new Promise((resolve, reject) => {
+      fs.unlink(cacheFile, (err) => {
+        if (err) {
+          debug('Failed to delete cache file', err);
+          // through
+        }
+
+        resolve();
+      });
+    })).then(data => {
+      // success
+    }).catch(err => {
+      debug('Failed to delete cache file (file may not exists).', err);
+      // through
+    });
+  }
+
   // private
-  lib.createCacheFileName = function(attachment) {
-    return crowi.cacheDir + '/attachment-' + attachment._id;
+  lib.createCacheFileName = function(fileId) {
+    return path.join(crowi.cacheDir, `attachment-${fileId}`);
   };
 
   // private
   lib.shouldUpdateCacheFile = function(filePath) {
-    var fs = require('fs');
-
     try {
       var stats = fs.statSync(filePath);
 

+ 3 - 3
local_modules/crowi-fileupload-local/index.js

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

+ 10 - 0
resource/css/_attachments.scss

@@ -0,0 +1,10 @@
+
+.attachment-delete-modal {
+  .attachment-delete-image {
+    text-align: center;
+
+    img {
+      max-width: 100%;
+    }
+  }
+}

+ 9 - 0
resource/css/_user.scss

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

+ 6 - 2
resource/css/crowi.scss

@@ -17,6 +17,7 @@
 @import 'user';
 @import 'portal';
 @import 'search';
+@import 'attachments';
 
 
 ul {
@@ -25,8 +26,7 @@ ul {
 
 
 .meta {
-
-  margin-top: 32px;
+  margin-top: 0;
   padding: 16px;
   color: #666;
   border-top: solid 1px #ccc;
@@ -40,6 +40,10 @@ ul {
   }
 }
 
+.page-meta {
+  margin-bottom: 0;
+}
+
 .help-block {
   font-size: .9em;
 }

+ 6 - 1
resource/js/app.js

@@ -8,6 +8,7 @@ import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
+import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 //import PageComment  from './components/PageComment';
 
@@ -22,7 +23,10 @@ if (mainContent !== null) {
 }
 
 // FIXME
-const crowi = new Crowi({me: $('#content-main').data('current-username')}, window);
+const crowi = new Crowi({
+  me: $('#content-main').data('current-username'),
+  csrfToken: $('#content-main').data('csrftoken'),
+}, window);
 window.crowi = crowi;
 crowi.fetchUsers();
 
@@ -33,6 +37,7 @@ const componentMappings = {
   'search-top': <HeaderSearchBox crowi={crowi} />,
   'search-page': <SearchPage crowi={crowi} />,
   'page-list-search': <PageListSearch crowi={crowi} />,
+  'page-attachment': <PageAttachment pageId={pageId} crowi={crowi} />,
 
   //'revision-history': <PageHistory pageId={pageId} />,
   //'page-comment': <PageComment />,

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

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

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

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

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

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

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

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

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

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

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

@@ -9,10 +9,20 @@ export default class User extends React.Component {
     const user = this.props.user;
     const userLink = '/user/' + user.username;
 
+    const username = this.props.username;
+    const name = this.props.name;
+
     return (
       <span className="user-component">
         <a href={userLink}>
           <UserPicture user={user} />
+
+          {username &&
+              <span className="user-component-username">@{user.username}</span>
+          }
+          {name &&
+              <span className="user-component-name">({user.name})</span>
+          }
         </a>
       </span>
     );
@@ -21,7 +31,11 @@ export default class User extends React.Component {
 
 User.propTypes = {
   user: PropTypes.object.isRequired,
+  name: PropTypes.bool.isRequired,
+  username: PropTypes.bool.isRequired,
 };
 
 User.defaultProps = {
+  name: false,
+  username: false,
 };

+ 0 - 19
resource/js/crowi.js

@@ -558,25 +558,6 @@ $(function() {
       return false;
     });
 
-    // attachment
-    var $pageAttachmentList = $('.page-attachments ul');
-    $.get('/_api/attachments.list', {page_id: pageId}, function(res) {
-      if (!res.ok) {
-        return ;
-      }
-
-      var attachments = res.attachments;
-      if (attachments.length > 0) {
-        $.each(attachments, function(i, file) {
-          $pageAttachmentList.append(
-          '<li><a href="' + file.fileUrl + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
-          );
-        })
-      } else {
-        $('.page-attachments').remove();
-      }
-    });
-
     // bookmark
     var $bookmarkButton = $('#bookmark-button');
     $.get('/_api/bookmarks.get', {page_id: pageId}, function(res) {

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

@@ -7,6 +7,7 @@ import axios from 'axios'
 export default class Crowi {
   constructor(context, window) {
     this.context = context;
+    this.csrfToken = context.csrfToken;
 
     this.location = window.location || {};
     this.document = window.document || {};
@@ -118,22 +119,27 @@ export default class Crowi {
   }
 
   apiGet(path, params) {
-    return this.apiRequest('get', path, params);
+    return this.apiRequest('get', path, {params: params});
   }
 
   apiPost(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
     return this.apiRequest('post', path, params);
   }
 
   apiRequest(method, path, params) {
+
     return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, {params})
+      axios[method](`/_api${path}`, params)
       .then(res => {
         if (res.data.ok) {
           resolve(res.data);
         } else {
           // FIXME?
-          reject(new Error(res.data));
+          reject(new Error(res.error));
         }
       }).catch(res => {
           // FIXME?