Show More
@@ -26,7 +26,8 b' import hashlib' | |||
|
26 | 26 | from rhodecode.lib.ext_json import json |
|
27 | 27 | from rhodecode.apps.file_store import utils |
|
28 | 28 | from rhodecode.apps.file_store.extensions import resolve_extensions |
|
29 |
from rhodecode.apps.file_store.exceptions import |
|
|
29 | from rhodecode.apps.file_store.exceptions import ( | |
|
30 | FileNotAllowedException, FileOverSizeException) | |
|
30 | 31 | |
|
31 | 32 | METADATA_VER = 'v1' |
|
32 | 33 | |
@@ -157,7 +158,7 b' class LocalFileStorage(object):' | |||
|
157 | 158 | return ext in [normalize_ext(x) for x in extensions] |
|
158 | 159 | |
|
159 | 160 | def save_file(self, file_obj, filename, directory=None, extensions=None, |
|
160 | extra_metadata=None, **kwargs): | |
|
161 | extra_metadata=None, max_filesize=None, **kwargs): | |
|
161 | 162 | """ |
|
162 | 163 | Saves a file object to the uploads location. |
|
163 | 164 | Returns the resolved filename, i.e. the directory + |
@@ -167,7 +168,9 b' class LocalFileStorage(object):' | |||
|
167 | 168 | :param filename: original filename |
|
168 | 169 | :param directory: relative path of sub-directory |
|
169 | 170 | :param extensions: iterable of allowed extensions, if not default |
|
171 | :param max_filesize: maximum size of file that should be allowed | |
|
170 | 172 | :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix |
|
173 | ||
|
171 | 174 | """ |
|
172 | 175 | |
|
173 | 176 | extensions = extensions or self.extensions |
@@ -199,6 +202,12 b' class LocalFileStorage(object):' | |||
|
199 | 202 | metadata = extra_metadata |
|
200 | 203 | |
|
201 | 204 | size = os.stat(path).st_size |
|
205 | ||
|
206 | if max_filesize and size > max_filesize: | |
|
207 | # free up the copied file, and raise exc | |
|
208 | os.remove(path) | |
|
209 | raise FileOverSizeException() | |
|
210 | ||
|
202 | 211 | file_hash = self.calculate_path_hash(path) |
|
203 | 212 | |
|
204 | 213 | metadata.update( |
@@ -79,6 +79,10 b' def includeme(config):' | |||
|
79 | 79 | pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/preview', repo_route=True) |
|
80 | 80 | |
|
81 | 81 | config.add_route( |
|
82 | name='repo_commit_comment_attachment_upload', | |
|
83 | pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/attachment_upload', repo_route=True) | |
|
84 | ||
|
85 | config.add_route( | |
|
82 | 86 | name='repo_commit_comment_delete', |
|
83 | 87 | pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True) |
|
84 | 88 |
@@ -28,6 +28,8 b' from pyramid.renderers import render' | |||
|
28 | 28 | from pyramid.response import Response |
|
29 | 29 | |
|
30 | 30 | from rhodecode.apps._base import RepoAppView |
|
31 | from rhodecode.apps.file_store import utils as store_utils | |
|
32 | from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException | |
|
31 | 33 | |
|
32 | 34 | from rhodecode.lib import diffs, codeblocks |
|
33 | 35 | from rhodecode.lib.auth import ( |
@@ -43,7 +45,7 b' from rhodecode.lib.utils2 import safe_un' | |||
|
43 | 45 | from rhodecode.lib.vcs.backends.base import EmptyCommit |
|
44 | 46 | from rhodecode.lib.vcs.exceptions import ( |
|
45 | 47 | RepositoryError, CommitDoesNotExistError) |
|
46 | from rhodecode.model.db import ChangesetComment, ChangesetStatus | |
|
48 | from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore | |
|
47 | 49 | from rhodecode.model.changeset_status import ChangesetStatusModel |
|
48 | 50 | from rhodecode.model.comment import CommentsModel |
|
49 | 51 | from rhodecode.model.meta import Session |
@@ -423,6 +425,101 b' class RepoCommitsView(RepoAppView):' | |||
|
423 | 425 | 'repository.read', 'repository.write', 'repository.admin') |
|
424 | 426 | @CSRFRequired() |
|
425 | 427 | @view_config( |
|
428 | route_name='repo_commit_comment_attachment_upload', request_method='POST', | |
|
429 | renderer='json_ext', xhr=True) | |
|
430 | def repo_commit_comment_attachment_upload(self): | |
|
431 | c = self.load_default_context() | |
|
432 | upload_key = 'attachment' | |
|
433 | ||
|
434 | file_obj = self.request.POST.get(upload_key) | |
|
435 | ||
|
436 | if file_obj is None: | |
|
437 | self.request.response.status = 400 | |
|
438 | return {'store_fid': None, | |
|
439 | 'access_path': None, | |
|
440 | 'error': '{} data field is missing'.format(upload_key)} | |
|
441 | ||
|
442 | if not hasattr(file_obj, 'filename'): | |
|
443 | self.request.response.status = 400 | |
|
444 | return {'store_fid': None, | |
|
445 | 'access_path': None, | |
|
446 | 'error': 'filename cannot be read from the data field'} | |
|
447 | ||
|
448 | filename = file_obj.filename | |
|
449 | file_display_name = filename | |
|
450 | ||
|
451 | metadata = { | |
|
452 | 'user_uploaded': {'username': self._rhodecode_user.username, | |
|
453 | 'user_id': self._rhodecode_user.user_id, | |
|
454 | 'ip': self._rhodecode_user.ip_addr}} | |
|
455 | ||
|
456 | # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size | |
|
457 | allowed_extensions = [ | |
|
458 | 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf', | |
|
459 | '.pptx', '.txt', '.xlsx', '.zip'] | |
|
460 | max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js | |
|
461 | ||
|
462 | try: | |
|
463 | storage = store_utils.get_file_storage(self.request.registry.settings) | |
|
464 | store_uid, metadata = storage.save_file( | |
|
465 | file_obj.file, filename, extra_metadata=metadata, | |
|
466 | extensions=allowed_extensions, max_filesize=max_file_size) | |
|
467 | except FileNotAllowedException: | |
|
468 | self.request.response.status = 400 | |
|
469 | permitted_extensions = ', '.join(allowed_extensions) | |
|
470 | error_msg = 'File `{}` is not allowed. ' \ | |
|
471 | 'Only following extensions are permitted: {}'.format( | |
|
472 | filename, permitted_extensions) | |
|
473 | return {'store_fid': None, | |
|
474 | 'access_path': None, | |
|
475 | 'error': error_msg} | |
|
476 | except FileOverSizeException: | |
|
477 | self.request.response.status = 400 | |
|
478 | limit_mb = h.format_byte_size_binary(max_file_size) | |
|
479 | return {'store_fid': None, | |
|
480 | 'access_path': None, | |
|
481 | 'error': 'File {} is exceeding allowed limit of {}.'.format( | |
|
482 | filename, limit_mb)} | |
|
483 | ||
|
484 | try: | |
|
485 | entry = FileStore.create( | |
|
486 | file_uid=store_uid, filename=metadata["filename"], | |
|
487 | file_hash=metadata["sha256"], file_size=metadata["size"], | |
|
488 | file_display_name=file_display_name, | |
|
489 | file_description=u'comment attachment `{}`'.format(safe_unicode(filename)), | |
|
490 | hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id, | |
|
491 | scope_repo_id=self.db_repo.repo_id | |
|
492 | ) | |
|
493 | Session().add(entry) | |
|
494 | Session().commit() | |
|
495 | log.debug('Stored upload in DB as %s', entry) | |
|
496 | except Exception: | |
|
497 | log.exception('Failed to store file %s', filename) | |
|
498 | self.request.response.status = 400 | |
|
499 | return {'store_fid': None, | |
|
500 | 'access_path': None, | |
|
501 | 'error': 'File {} failed to store in DB.'.format(filename)} | |
|
502 | ||
|
503 | Session().commit() | |
|
504 | ||
|
505 | return { | |
|
506 | 'store_fid': store_uid, | |
|
507 | 'access_path': h.route_path( | |
|
508 | 'download_file', fid=store_uid), | |
|
509 | 'fqn_access_path': h.route_url( | |
|
510 | 'download_file', fid=store_uid), | |
|
511 | 'repo_access_path': h.route_path( | |
|
512 | 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid), | |
|
513 | 'repo_fqn_access_path': h.route_url( | |
|
514 | 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid), | |
|
515 | } | |
|
516 | ||
|
517 | @LoginRequired() | |
|
518 | @NotAnonymous() | |
|
519 | @HasRepoPermissionAnyDecorator( | |
|
520 | 'repository.read', 'repository.write', 'repository.admin') | |
|
521 | @CSRFRequired() | |
|
522 | @view_config( | |
|
426 | 523 | route_name='repo_commit_comment_delete', request_method='POST', |
|
427 | 524 | renderer='json_ext') |
|
428 | 525 | def repo_commit_comment_delete(self): |
@@ -5115,7 +5115,7 b' class FileStore(Base, BaseModel):' | |||
|
5115 | 5115 | # if repo/repo_group reference is set, check for permissions |
|
5116 | 5116 | check_acl = Column('check_acl', Boolean(), nullable=False, default=True) |
|
5117 | 5117 | |
|
5118 |
# hidden defines an attach |
|
|
5118 | # hidden defines an attachment that should be hidden from showing in artifact listing | |
|
5119 | 5119 | hidden = Column('hidden', Boolean(), nullable=False, default=False) |
|
5120 | 5120 | |
|
5121 | 5121 | user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False) |
@@ -5147,8 +5147,8 b' class FileStore(Base, BaseModel):' | |||
|
5147 | 5147 | |
|
5148 | 5148 | @classmethod |
|
5149 | 5149 | def create(cls, file_uid, filename, file_hash, file_size, file_display_name='', |
|
5150 |
file_description='', enabled=True, check_acl=True, |
|
|
5151 | scope_user_id=None, scope_repo_id=None, scope_repo_group_id=None): | |
|
5150 | file_description='', enabled=True, hidden=False, check_acl=True, | |
|
5151 | user_id=None, scope_user_id=None, scope_repo_id=None, scope_repo_group_id=None): | |
|
5152 | 5152 | |
|
5153 | 5153 | store_entry = FileStore() |
|
5154 | 5154 | store_entry.file_uid = file_uid |
@@ -5160,11 +5160,13 b' class FileStore(Base, BaseModel):' | |||
|
5160 | 5160 | |
|
5161 | 5161 | store_entry.check_acl = check_acl |
|
5162 | 5162 | store_entry.enabled = enabled |
|
5163 | store_entry.hidden = hidden | |
|
5163 | 5164 | |
|
5164 | 5165 | store_entry.user_id = user_id |
|
5165 | 5166 | store_entry.scope_user_id = scope_user_id |
|
5166 | 5167 | store_entry.scope_repo_id = scope_repo_id |
|
5167 | 5168 | store_entry.scope_repo_group_id = scope_repo_group_id |
|
5169 | ||
|
5168 | 5170 | return store_entry |
|
5169 | 5171 | |
|
5170 | 5172 | @classmethod |
@@ -539,13 +539,40 b' form.comment-form {' | |||
|
539 | 539 | } |
|
540 | 540 | |
|
541 | 541 | .comment-area-footer { |
|
542 | display: flex; | |
|
542 | min-height: 30px; | |
|
543 | 543 | } |
|
544 | 544 | |
|
545 | 545 | .comment-footer .toolbar { |
|
546 | 546 | |
|
547 | 547 | } |
|
548 | 548 | |
|
549 | .comment-attachment-uploader { | |
|
550 | border: 1px dashed white; | |
|
551 | border-radius: @border-radius; | |
|
552 | margin-top: -10px; | |
|
553 | ||
|
554 | &.dz-drag-hover { | |
|
555 | border-color: @grey3; | |
|
556 | } | |
|
557 | ||
|
558 | .dz-error-message { | |
|
559 | padding-top: 0; | |
|
560 | } | |
|
561 | } | |
|
562 | ||
|
563 | .comment-attachment-text { | |
|
564 | clear: both; | |
|
565 | font-size: 11px; | |
|
566 | color: #8F8F8F; | |
|
567 | width: 100%; | |
|
568 | .pick-attachment { | |
|
569 | color: #8F8F8F; | |
|
570 | } | |
|
571 | .pick-attachment:hover { | |
|
572 | color: @rcblue; | |
|
573 | } | |
|
574 | } | |
|
575 | ||
|
549 | 576 | .nav-links { |
|
550 | 577 | padding: 0; |
|
551 | 578 | margin: 0; |
@@ -579,7 +606,6 b' form.comment-form {' | |||
|
579 | 606 | |
|
580 | 607 | .toolbar-text { |
|
581 | 608 | float: left; |
|
582 | margin: -5px 0px 0px 0px; | |
|
583 | 609 | font-size: 12px; |
|
584 | 610 | } |
|
585 | 611 |
@@ -204,6 +204,7 b' div.markdown-block img {' | |||
|
204 | 204 | border-style: none; |
|
205 | 205 | background-color: #fff; |
|
206 | 206 | padding-right: 20px; |
|
207 | max-width: 100%; | |
|
207 | 208 | } |
|
208 | 209 | |
|
209 | 210 |
@@ -118,6 +118,10 b' table.dataTable {' | |||
|
118 | 118 | } |
|
119 | 119 | } |
|
120 | 120 | |
|
121 | &.td-sha { | |
|
122 | white-space: nowrap; | |
|
123 | } | |
|
124 | ||
|
121 | 125 | &.td-graphbox { |
|
122 | 126 | width: 100px; |
|
123 | 127 | max-width: 100px; |
@@ -174,6 +174,7 b' function registerRCRoutes() {' | |||
|
174 | 174 | pyroutes.register('repo_commit_data', '/%(repo_name)s/changeset-data/%(commit_id)s', ['repo_name', 'commit_id']); |
|
175 | 175 | pyroutes.register('repo_commit_comment_create', '/%(repo_name)s/changeset/%(commit_id)s/comment/create', ['repo_name', 'commit_id']); |
|
176 | 176 | pyroutes.register('repo_commit_comment_preview', '/%(repo_name)s/changeset/%(commit_id)s/comment/preview', ['repo_name', 'commit_id']); |
|
177 | pyroutes.register('repo_commit_comment_attachment_upload', '/%(repo_name)s/changeset/%(commit_id)s/comment/attachment_upload', ['repo_name', 'commit_id']); | |
|
177 | 178 | pyroutes.register('repo_commit_comment_delete', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/delete', ['repo_name', 'commit_id', 'comment_id']); |
|
178 | 179 | pyroutes.register('repo_commit_raw_deprecated', '/%(repo_name)s/raw-changeset/%(commit_id)s', ['repo_name', 'commit_id']); |
|
179 | 180 | pyroutes.register('repo_archivefile', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']); |
@@ -654,6 +654,95 b' var CommentsController = function() {' | |||
|
654 | 654 | }, 100); |
|
655 | 655 | } |
|
656 | 656 | |
|
657 | // add dropzone support | |
|
658 | var insertAttachmentText = function (cm, attachmentName, attachmentStoreUrl, isRendered) { | |
|
659 | var renderer = templateContext.visual.default_renderer; | |
|
660 | if (renderer == 'rst') { | |
|
661 | var attachmentUrl = '`#{0} <{1}>`_'.format(attachmentName, attachmentStoreUrl); | |
|
662 | if (isRendered){ | |
|
663 | attachmentUrl = '\n.. image:: {0}'.format(attachmentStoreUrl); | |
|
664 | } | |
|
665 | } else if (renderer == 'markdown') { | |
|
666 | var attachmentUrl = '[{0}]({1})'.format(attachmentName, attachmentStoreUrl); | |
|
667 | if (isRendered){ | |
|
668 | attachmentUrl = '!' + attachmentUrl; | |
|
669 | } | |
|
670 | } else { | |
|
671 | var attachmentUrl = '{}'.format(attachmentStoreUrl); | |
|
672 | } | |
|
673 | cm.replaceRange(attachmentUrl+'\n', CodeMirror.Pos(cm.lastLine())); | |
|
674 | ||
|
675 | return false; | |
|
676 | }; | |
|
677 | ||
|
678 | //see: https://www.dropzonejs.com/#configuration | |
|
679 | var storeUrl = pyroutes.url('repo_commit_comment_attachment_upload', | |
|
680 | {'repo_name': templateContext.repo_name, | |
|
681 | 'commit_id': templateContext.commit_data.commit_id}) | |
|
682 | ||
|
683 | var previewTmpl = $(formElement).find('.comment-attachment-uploader-template').get(0).innerHTML; | |
|
684 | var selectLink = $(formElement).find('.pick-attachment').get(0); | |
|
685 | $(formElement).find('.comment-attachment-uploader').dropzone({ | |
|
686 | url: storeUrl, | |
|
687 | headers: {"X-CSRF-Token": CSRF_TOKEN}, | |
|
688 | paramName: function () { | |
|
689 | return "attachment" | |
|
690 | }, // The name that will be used to transfer the file | |
|
691 | clickable: selectLink, | |
|
692 | parallelUploads: 1, | |
|
693 | maxFiles: 10, | |
|
694 | maxFilesize: templateContext.attachment_store.max_file_size_mb, | |
|
695 | uploadMultiple: false, | |
|
696 | autoProcessQueue: true, // if false queue will not be processed automatically. | |
|
697 | createImageThumbnails: false, | |
|
698 | previewTemplate: previewTmpl, | |
|
699 | ||
|
700 | accept: function (file, done) { | |
|
701 | done(); | |
|
702 | }, | |
|
703 | init: function () { | |
|
704 | ||
|
705 | this.on("sending", function (file, xhr, formData) { | |
|
706 | $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').hide(); | |
|
707 | $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').show(); | |
|
708 | }); | |
|
709 | ||
|
710 | this.on("success", function (file, response) { | |
|
711 | $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').show(); | |
|
712 | $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide(); | |
|
713 | ||
|
714 | var isRendered = false; | |
|
715 | var ext = file.name.split('.').pop(); | |
|
716 | var imageExts = templateContext.attachment_store.image_ext; | |
|
717 | if (imageExts.indexOf(ext) !== -1){ | |
|
718 | isRendered = true; | |
|
719 | } | |
|
720 | ||
|
721 | insertAttachmentText(cm, file.name, response.repo_fqn_access_path, isRendered) | |
|
722 | }); | |
|
723 | ||
|
724 | this.on("error", function (file, errorMessage, xhr) { | |
|
725 | $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide(); | |
|
726 | ||
|
727 | var error = null; | |
|
728 | ||
|
729 | if (xhr !== undefined){ | |
|
730 | var httpStatus = xhr.status + " " + xhr.statusText; | |
|
731 | if (xhr.status >= 500) { | |
|
732 | error = httpStatus; | |
|
733 | } | |
|
734 | } | |
|
735 | ||
|
736 | if (error === null) { | |
|
737 | error = errorMessage.error || errorMessage || httpStatus; | |
|
738 | } | |
|
739 | $(file.previewElement).find('.dz-error-message').html('ERROR: {0}'.format(error)); | |
|
740 | ||
|
741 | }); | |
|
742 | } | |
|
743 | }); | |
|
744 | ||
|
745 | ||
|
657 | 746 | return commentForm; |
|
658 | 747 | }; |
|
659 | 748 |
@@ -34,6 +34,11 b" c.template_context['search_context'] = {" | |||
|
34 | 34 | 'repo_view_type': c.template_context.get('repo_view_type'), |
|
35 | 35 | } |
|
36 | 36 | |
|
37 | c.template_context['attachment_store'] = { | |
|
38 | 'max_file_size_mb': 10, | |
|
39 | 'image_ext': ["png", "jpg", "gif", "jpeg"] | |
|
40 | } | |
|
41 | ||
|
37 | 42 | %> |
|
38 | 43 | <html xmlns="http://www.w3.org/1999/xhtml"> |
|
39 | 44 | <head> |
@@ -257,7 +257,6 b'' | |||
|
257 | 257 | }); |
|
258 | 258 | % endif |
|
259 | 259 | |
|
260 | ||
|
261 | 260 | </script> |
|
262 | 261 | % else: |
|
263 | 262 | ## form state when not logged in |
@@ -309,8 +308,8 b'' | |||
|
309 | 308 | |
|
310 | 309 | |
|
311 | 310 | <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)"> |
|
311 | ||
|
312 | 312 | ## comment injected based on assumption that user is logged in |
|
313 | ||
|
314 | 313 | <form ${'id="{}"'.format(form_id) if form_id else '' |n} action="#" method="GET"> |
|
315 | 314 | |
|
316 | 315 | <div class="comment-area"> |
@@ -341,7 +340,7 b'' | |||
|
341 | 340 | </div> |
|
342 | 341 | </div> |
|
343 | 342 | |
|
344 | <div class="comment-area-footer"> | |
|
343 | <div class="comment-area-footer comment-attachment-uploader"> | |
|
345 | 344 | <div class="toolbar"> |
|
346 | 345 | <div class="toolbar-text"> |
|
347 | 346 | ${(_('Comments parsed using %s syntax with %s, and %s actions support.') % ( |
@@ -351,6 +350,23 b'' | |||
|
351 | 350 | ) |
|
352 | 351 | )|n} |
|
353 | 352 | </div> |
|
353 | ||
|
354 | <div class="comment-attachment-text"> | |
|
355 | <div class="dropzone-text"> | |
|
356 | ${_("Drag'n Drop files here or")} <span class="link pick-attachment">${_('Choose your files')}</span>.<br> | |
|
357 | </div> | |
|
358 | <div class="dropzone-upload" style="display:none"> | |
|
359 | <i class="icon-spin animate-spin"></i> ${_('uploading...')} | |
|
360 | </div> | |
|
361 | </div> | |
|
362 | ||
|
363 | ## comments dropzone template, empty on purpose | |
|
364 | <div style="display: none" class="comment-attachment-uploader-template"> | |
|
365 | <div class="dz-file-preview" style="margin: 0"> | |
|
366 | <div class="dz-error-message"></div> | |
|
367 | </div> | |
|
368 | </div> | |
|
369 | ||
|
354 | 370 | </div> |
|
355 | 371 | </div> |
|
356 | 372 | </div> |
General Comments 0
You need to be logged in to leave comments.
Login now