diff --git a/rhodecode/apps/file_store/local_store.py b/rhodecode/apps/file_store/local_store.py --- a/rhodecode/apps/file_store/local_store.py +++ b/rhodecode/apps/file_store/local_store.py @@ -26,7 +26,8 @@ import hashlib from rhodecode.lib.ext_json import json from rhodecode.apps.file_store import utils from rhodecode.apps.file_store.extensions import resolve_extensions -from rhodecode.apps.file_store.exceptions import FileNotAllowedException +from rhodecode.apps.file_store.exceptions import ( + FileNotAllowedException, FileOverSizeException) METADATA_VER = 'v1' @@ -157,7 +158,7 @@ class LocalFileStorage(object): return ext in [normalize_ext(x) for x in extensions] def save_file(self, file_obj, filename, directory=None, extensions=None, - extra_metadata=None, **kwargs): + extra_metadata=None, max_filesize=None, **kwargs): """ Saves a file object to the uploads location. Returns the resolved filename, i.e. the directory + @@ -167,7 +168,9 @@ class LocalFileStorage(object): :param filename: original filename :param directory: relative path of sub-directory :param extensions: iterable of allowed extensions, if not default + :param max_filesize: maximum size of file that should be allowed :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix + """ extensions = extensions or self.extensions @@ -199,6 +202,12 @@ class LocalFileStorage(object): metadata = extra_metadata size = os.stat(path).st_size + + if max_filesize and size > max_filesize: + # free up the copied file, and raise exc + os.remove(path) + raise FileOverSizeException() + file_hash = self.calculate_path_hash(path) metadata.update( diff --git a/rhodecode/apps/repository/__init__.py b/rhodecode/apps/repository/__init__.py --- a/rhodecode/apps/repository/__init__.py +++ b/rhodecode/apps/repository/__init__.py @@ -79,6 +79,10 @@ def includeme(config): pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/preview', repo_route=True) config.add_route( + name='repo_commit_comment_attachment_upload', + pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/attachment_upload', repo_route=True) + + config.add_route( name='repo_commit_comment_delete', pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True) diff --git a/rhodecode/apps/repository/views/repo_commits.py b/rhodecode/apps/repository/views/repo_commits.py --- a/rhodecode/apps/repository/views/repo_commits.py +++ b/rhodecode/apps/repository/views/repo_commits.py @@ -28,6 +28,8 @@ from pyramid.renderers import render from pyramid.response import Response from rhodecode.apps._base import RepoAppView +from rhodecode.apps.file_store import utils as store_utils +from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException from rhodecode.lib import diffs, codeblocks from rhodecode.lib.auth import ( @@ -43,7 +45,7 @@ from rhodecode.lib.utils2 import safe_un from rhodecode.lib.vcs.backends.base import EmptyCommit from rhodecode.lib.vcs.exceptions import ( RepositoryError, CommitDoesNotExistError) -from rhodecode.model.db import ChangesetComment, ChangesetStatus +from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore from rhodecode.model.changeset_status import ChangesetStatusModel from rhodecode.model.comment import CommentsModel from rhodecode.model.meta import Session @@ -423,6 +425,101 @@ class RepoCommitsView(RepoAppView): 'repository.read', 'repository.write', 'repository.admin') @CSRFRequired() @view_config( + route_name='repo_commit_comment_attachment_upload', request_method='POST', + renderer='json_ext', xhr=True) + def repo_commit_comment_attachment_upload(self): + c = self.load_default_context() + upload_key = 'attachment' + + file_obj = self.request.POST.get(upload_key) + + if file_obj is None: + self.request.response.status = 400 + return {'store_fid': None, + 'access_path': None, + 'error': '{} data field is missing'.format(upload_key)} + + if not hasattr(file_obj, 'filename'): + self.request.response.status = 400 + return {'store_fid': None, + 'access_path': None, + 'error': 'filename cannot be read from the data field'} + + filename = file_obj.filename + file_display_name = filename + + metadata = { + 'user_uploaded': {'username': self._rhodecode_user.username, + 'user_id': self._rhodecode_user.user_id, + 'ip': self._rhodecode_user.ip_addr}} + + # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size + allowed_extensions = [ + 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf', + '.pptx', '.txt', '.xlsx', '.zip'] + max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js + + try: + storage = store_utils.get_file_storage(self.request.registry.settings) + store_uid, metadata = storage.save_file( + file_obj.file, filename, extra_metadata=metadata, + extensions=allowed_extensions, max_filesize=max_file_size) + except FileNotAllowedException: + self.request.response.status = 400 + permitted_extensions = ', '.join(allowed_extensions) + error_msg = 'File `{}` is not allowed. ' \ + 'Only following extensions are permitted: {}'.format( + filename, permitted_extensions) + return {'store_fid': None, + 'access_path': None, + 'error': error_msg} + except FileOverSizeException: + self.request.response.status = 400 + limit_mb = h.format_byte_size_binary(max_file_size) + return {'store_fid': None, + 'access_path': None, + 'error': 'File {} is exceeding allowed limit of {}.'.format( + filename, limit_mb)} + + try: + entry = FileStore.create( + file_uid=store_uid, filename=metadata["filename"], + file_hash=metadata["sha256"], file_size=metadata["size"], + file_display_name=file_display_name, + file_description=u'comment attachment `{}`'.format(safe_unicode(filename)), + hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id, + scope_repo_id=self.db_repo.repo_id + ) + Session().add(entry) + Session().commit() + log.debug('Stored upload in DB as %s', entry) + except Exception: + log.exception('Failed to store file %s', filename) + self.request.response.status = 400 + return {'store_fid': None, + 'access_path': None, + 'error': 'File {} failed to store in DB.'.format(filename)} + + Session().commit() + + return { + 'store_fid': store_uid, + 'access_path': h.route_path( + 'download_file', fid=store_uid), + 'fqn_access_path': h.route_url( + 'download_file', fid=store_uid), + 'repo_access_path': h.route_path( + 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid), + 'repo_fqn_access_path': h.route_url( + 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid), + } + + @LoginRequired() + @NotAnonymous() + @HasRepoPermissionAnyDecorator( + 'repository.read', 'repository.write', 'repository.admin') + @CSRFRequired() + @view_config( route_name='repo_commit_comment_delete', request_method='POST', renderer='json_ext') def repo_commit_comment_delete(self): diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -5115,7 +5115,7 @@ class FileStore(Base, BaseModel): # if repo/repo_group reference is set, check for permissions check_acl = Column('check_acl', Boolean(), nullable=False, default=True) - # hidden defines an attachement that should be hidden from showing in artifact listing + # hidden defines an attachment that should be hidden from showing in artifact listing hidden = Column('hidden', Boolean(), nullable=False, default=False) user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False) @@ -5147,8 +5147,8 @@ class FileStore(Base, BaseModel): @classmethod def create(cls, file_uid, filename, file_hash, file_size, file_display_name='', - file_description='', enabled=True, check_acl=True, user_id=None, - scope_user_id=None, scope_repo_id=None, scope_repo_group_id=None): + file_description='', enabled=True, hidden=False, check_acl=True, + user_id=None, scope_user_id=None, scope_repo_id=None, scope_repo_group_id=None): store_entry = FileStore() store_entry.file_uid = file_uid @@ -5160,11 +5160,13 @@ class FileStore(Base, BaseModel): store_entry.check_acl = check_acl store_entry.enabled = enabled + store_entry.hidden = hidden store_entry.user_id = user_id store_entry.scope_user_id = scope_user_id store_entry.scope_repo_id = scope_repo_id store_entry.scope_repo_group_id = scope_repo_group_id + return store_entry @classmethod diff --git a/rhodecode/public/css/comments.less b/rhodecode/public/css/comments.less --- a/rhodecode/public/css/comments.less +++ b/rhodecode/public/css/comments.less @@ -539,13 +539,40 @@ form.comment-form { } .comment-area-footer { - display: flex; + min-height: 30px; } .comment-footer .toolbar { } +.comment-attachment-uploader { + border: 1px dashed white; + border-radius: @border-radius; + margin-top: -10px; + + &.dz-drag-hover { + border-color: @grey3; + } + + .dz-error-message { + padding-top: 0; + } +} + +.comment-attachment-text { + clear: both; + font-size: 11px; + color: #8F8F8F; + width: 100%; + .pick-attachment { + color: #8F8F8F; + } + .pick-attachment:hover { + color: @rcblue; + } +} + .nav-links { padding: 0; margin: 0; @@ -579,7 +606,6 @@ form.comment-form { .toolbar-text { float: left; - margin: -5px 0px 0px 0px; font-size: 12px; } diff --git a/rhodecode/public/css/legacy_code_styles.less b/rhodecode/public/css/legacy_code_styles.less --- a/rhodecode/public/css/legacy_code_styles.less +++ b/rhodecode/public/css/legacy_code_styles.less @@ -204,6 +204,7 @@ div.markdown-block img { border-style: none; background-color: #fff; padding-right: 20px; + max-width: 100%; } diff --git a/rhodecode/public/css/tables.less b/rhodecode/public/css/tables.less --- a/rhodecode/public/css/tables.less +++ b/rhodecode/public/css/tables.less @@ -118,6 +118,10 @@ table.dataTable { } } + &.td-sha { + white-space: nowrap; + } + &.td-graphbox { width: 100px; max-width: 100px; diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -174,6 +174,7 @@ function registerRCRoutes() { pyroutes.register('repo_commit_data', '/%(repo_name)s/changeset-data/%(commit_id)s', ['repo_name', 'commit_id']); pyroutes.register('repo_commit_comment_create', '/%(repo_name)s/changeset/%(commit_id)s/comment/create', ['repo_name', 'commit_id']); pyroutes.register('repo_commit_comment_preview', '/%(repo_name)s/changeset/%(commit_id)s/comment/preview', ['repo_name', 'commit_id']); + pyroutes.register('repo_commit_comment_attachment_upload', '/%(repo_name)s/changeset/%(commit_id)s/comment/attachment_upload', ['repo_name', 'commit_id']); pyroutes.register('repo_commit_comment_delete', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/delete', ['repo_name', 'commit_id', 'comment_id']); pyroutes.register('repo_commit_raw_deprecated', '/%(repo_name)s/raw-changeset/%(commit_id)s', ['repo_name', 'commit_id']); pyroutes.register('repo_archivefile', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']); diff --git a/rhodecode/public/js/src/rhodecode/comments.js b/rhodecode/public/js/src/rhodecode/comments.js --- a/rhodecode/public/js/src/rhodecode/comments.js +++ b/rhodecode/public/js/src/rhodecode/comments.js @@ -654,6 +654,95 @@ var CommentsController = function() { }, 100); } + // add dropzone support + var insertAttachmentText = function (cm, attachmentName, attachmentStoreUrl, isRendered) { + var renderer = templateContext.visual.default_renderer; + if (renderer == 'rst') { + var attachmentUrl = '`#{0} <{1}>`_'.format(attachmentName, attachmentStoreUrl); + if (isRendered){ + attachmentUrl = '\n.. image:: {0}'.format(attachmentStoreUrl); + } + } else if (renderer == 'markdown') { + var attachmentUrl = '[{0}]({1})'.format(attachmentName, attachmentStoreUrl); + if (isRendered){ + attachmentUrl = '!' + attachmentUrl; + } + } else { + var attachmentUrl = '{}'.format(attachmentStoreUrl); + } + cm.replaceRange(attachmentUrl+'\n', CodeMirror.Pos(cm.lastLine())); + + return false; + }; + + //see: https://www.dropzonejs.com/#configuration + var storeUrl = pyroutes.url('repo_commit_comment_attachment_upload', + {'repo_name': templateContext.repo_name, + 'commit_id': templateContext.commit_data.commit_id}) + + var previewTmpl = $(formElement).find('.comment-attachment-uploader-template').get(0).innerHTML; + var selectLink = $(formElement).find('.pick-attachment').get(0); + $(formElement).find('.comment-attachment-uploader').dropzone({ + url: storeUrl, + headers: {"X-CSRF-Token": CSRF_TOKEN}, + paramName: function () { + return "attachment" + }, // The name that will be used to transfer the file + clickable: selectLink, + parallelUploads: 1, + maxFiles: 10, + maxFilesize: templateContext.attachment_store.max_file_size_mb, + uploadMultiple: false, + autoProcessQueue: true, // if false queue will not be processed automatically. + createImageThumbnails: false, + previewTemplate: previewTmpl, + + accept: function (file, done) { + done(); + }, + init: function () { + + this.on("sending", function (file, xhr, formData) { + $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').hide(); + $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').show(); + }); + + this.on("success", function (file, response) { + $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').show(); + $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide(); + + var isRendered = false; + var ext = file.name.split('.').pop(); + var imageExts = templateContext.attachment_store.image_ext; + if (imageExts.indexOf(ext) !== -1){ + isRendered = true; + } + + insertAttachmentText(cm, file.name, response.repo_fqn_access_path, isRendered) + }); + + this.on("error", function (file, errorMessage, xhr) { + $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide(); + + var error = null; + + if (xhr !== undefined){ + var httpStatus = xhr.status + " " + xhr.statusText; + if (xhr.status >= 500) { + error = httpStatus; + } + } + + if (error === null) { + error = errorMessage.error || errorMessage || httpStatus; + } + $(file.previewElement).find('.dz-error-message').html('ERROR: {0}'.format(error)); + + }); + } + }); + + return commentForm; }; diff --git a/rhodecode/templates/base/root.mako b/rhodecode/templates/base/root.mako --- a/rhodecode/templates/base/root.mako +++ b/rhodecode/templates/base/root.mako @@ -34,6 +34,11 @@ c.template_context['search_context'] = { 'repo_view_type': c.template_context.get('repo_view_type'), } +c.template_context['attachment_store'] = { + 'max_file_size_mb': 10, + 'image_ext': ["png", "jpg", "gif", "jpeg"] +} + %>
diff --git a/rhodecode/templates/changeset/changeset_file_comment.mako b/rhodecode/templates/changeset/changeset_file_comment.mako --- a/rhodecode/templates/changeset/changeset_file_comment.mako +++ b/rhodecode/templates/changeset/changeset_file_comment.mako @@ -257,7 +257,6 @@ }); % endif - % else: ## form state when not logged in @@ -309,8 +308,8 @@ <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)"> + ## comment injected based on assumption that user is logged in -