Package trac :: Module attachment

Source Code for Module trac.attachment

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2003-2020 Edgewall Software 
   4  # Copyright (C) 2003-2005 Jonas Borgström <[email protected]> 
   5  # Copyright (C) 2005 Christopher Lenz <[email protected]> 
   6  # All rights reserved. 
   7  # 
   8  # This software is licensed as described in the file COPYING, which 
   9  # you should have received as part of this distribution. The terms 
  10  # are also available at https://trac.edgewall.org/wiki/TracLicense. 
  11  # 
  12  # This software consists of voluntary contributions made by many 
  13  # individuals. For the exact contribution history, see the revision 
  14  # history and logs, available at https://trac.edgewall.org/log/. 
  15  # 
  16  # Author: Jonas Borgström <[email protected]> 
  17  #         Christopher Lenz <[email protected]> 
  18   
  19  from datetime import datetime 
  20  from tempfile import TemporaryFile 
  21  from zipfile import ZipFile, ZIP_DEFLATED 
  22  import errno 
  23  import hashlib 
  24  import os.path 
  25  import posixpath 
  26  import re 
  27  import shutil 
  28  import sys 
  29  import unicodedata 
  30   
  31  from genshi.builder import tag 
  32   
  33  from trac.admin import AdminCommandError, IAdminCommandProvider, PrefixList, \ 
  34                         console_datetime_format, get_dir_list 
  35  from trac.config import BoolOption, IntOption 
  36  from trac.core import * 
  37  from trac.mimeview import * 
  38  from trac.perm import PermissionError, IPermissionPolicy 
  39  from trac.resource import * 
  40  from trac.search import search_to_sql, shorten_result 
  41  from trac.util import content_disposition, create_zipinfo, get_reporter_id 
  42  from trac.util.datefmt import datetime_now, format_datetime, from_utimestamp, \ 
  43                                to_datetime, to_utimestamp, utc 
  44  from trac.util.text import exception_to_unicode, path_to_unicode, \ 
  45                             pretty_size, print_table, stripws, unicode_unquote 
  46  from trac.util.translation import _, tag_ 
  47  from trac.web import HTTPBadRequest, IRequestHandler, RequestDone 
  48  from trac.web.chrome import (INavigationContributor, add_ctxtnav, add_link, 
  49                               add_stylesheet, web_context, add_warning) 
  50  from trac.web.href import Href 
  51  from trac.wiki.api import IWikiSyntaxProvider 
  52  from trac.wiki.formatter import format_to 
53 54 55 -class InvalidAttachment(TracError):
56 """Exception raised when attachment validation fails."""
57
58 59 -class IAttachmentChangeListener(Interface):
60 """Extension point interface for components that require 61 notification when attachments are created or deleted.""" 62
63 - def attachment_added(attachment):
64 """Called when an attachment is added."""
65
66 - def attachment_deleted(attachment):
67 """Called when an attachment is deleted."""
68
69 - def attachment_reparented(attachment, old_parent_realm, old_parent_id):
70 """Called when an attachment is reparented."""
71
72 73 -class IAttachmentManipulator(Interface):
74 """Extension point interface for components that need to 75 manipulate attachments. 76 77 Unlike change listeners, a manipulator can reject changes being 78 committed to the database.""" 79
80 - def prepare_attachment(req, attachment, fields):
81 """Not currently called, but should be provided for future 82 compatibility."""
83
84 - def validate_attachment(req, attachment):
85 """Validate an attachment after upload but before being stored 86 in Trac environment. 87 88 Must return a list of ``(field, message)`` tuples, one for 89 each problem detected. ``field`` can be any of 90 ``description``, ``username``, ``filename``, ``content``, or 91 `None` to indicate an overall problem with the 92 attachment. Therefore, a return value of ``[]`` means 93 everything is OK."""
94
95 96 -class ILegacyAttachmentPolicyDelegate(Interface):
97 """Interface that can be used by plugins to seamlessly participate 98 to the legacy way of checking for attachment permissions. 99 100 This should no longer be necessary once it becomes easier to 101 setup fine-grained permissions in the default permission store. 102 """ 103
104 - def check_attachment_permission(action, username, resource, perm):
105 """Return the usual `True`/`False`/`None` security policy 106 decision appropriate for the requested action on an 107 attachment. 108 109 :param action: one of ATTACHMENT_VIEW, ATTACHMENT_CREATE, 110 ATTACHMENT_DELETE 111 :param username: the user string 112 :param resource: the `~trac.resource.Resource` for the 113 attachment. Note that when 114 ATTACHMENT_CREATE is checked, the 115 resource ``.id`` will be `None`. 116 :param perm: the permission cache for that username and resource 117 """
118
119 120 -class AttachmentModule(Component):
121 122 implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider, 123 IResourceManager) 124 125 realm = 'attachment' 126 is_valid_default_handler = False 127 128 change_listeners = ExtensionPoint(IAttachmentChangeListener) 129 manipulators = ExtensionPoint(IAttachmentManipulator) 130 131 CHUNK_SIZE = 4096 132 133 max_size = IntOption('attachment', 'max_size', 262144, 134 """Maximum allowed file size (in bytes) for attachments.""") 135 136 max_zip_size = IntOption('attachment', 'max_zip_size', 2097152, 137 """Maximum allowed total size (in bytes) for an attachment list to be 138 downloadable as a `.zip`. Set this to -1 to disable download as `.zip`. 139 (''since 1.0'')""") 140 141 render_unsafe_content = BoolOption('attachment', 'render_unsafe_content', 142 'false', 143 """Whether attachments should be rendered in the browser, or 144 only made downloadable. 145 146 Pretty much any file may be interpreted as HTML by the browser, 147 which allows a malicious user to attach a file containing cross-site 148 scripting attacks. 149 150 For public sites where anonymous users can create attachments it is 151 recommended to leave this option disabled.""") 152 153 # INavigationContributor methods 154
155 - def get_active_navigation_item(self, req):
156 return req.args.get('realm')
157
158 - def get_navigation_items(self, req):
159 return []
160 161 # IRequestHandler methods 162
163 - def match_request(self, req):
164 match = re.match(r'/(raw-|zip-)?attachment/([^/]+)(?:/(.*))?$', 165 req.path_info) 166 if match: 167 format, realm, path = match.groups() 168 if format: 169 req.args['format'] = format[:-1] 170 req.args['realm'] = realm 171 if path: 172 req.args['path'] = path 173 return True
174
175 - def process_request(self, req):
176 parent_id = None 177 parent_realm = req.args.get('realm') 178 path = req.args.get('path') 179 filename = None 180 181 if not parent_realm or not path: 182 raise HTTPBadRequest(_('Bad request')) 183 if parent_realm == 'attachment': 184 raise TracError(tag_("%(realm)s is not a valid parent realm", 185 realm=tag.code(parent_realm))) 186 187 parent_realm = Resource(parent_realm) 188 action = req.args.get('action', 'view') 189 if action == 'new': 190 parent_id = path.rstrip('/') 191 else: 192 last_slash = path.rfind('/') 193 if last_slash == -1: 194 parent_id, filename = path, '' 195 else: 196 parent_id, filename = path[:last_slash], path[last_slash + 1:] 197 198 parent = parent_realm(id=parent_id) 199 if not resource_exists(self.env, parent): 200 raise ResourceNotFound( 201 _("Parent resource %(parent)s doesn't exist", 202 parent=get_resource_name(self.env, parent))) 203 204 # Link the attachment page to parent resource 205 parent_name = get_resource_name(self.env, parent) 206 parent_url = get_resource_url(self.env, parent, req.href) 207 add_link(req, 'up', parent_url, parent_name) 208 add_ctxtnav(req, _('Back to %(parent)s', parent=parent_name), 209 parent_url) 210 211 if not filename: # there's a trailing '/' 212 if req.args.get('format') == 'zip': 213 self._download_as_zip(req, parent) 214 elif action != 'new': 215 return self._render_list(req, parent) 216 217 attachment = Attachment(self.env, parent.child(self.realm, filename)) 218 219 if req.method == 'POST': 220 if action == 'new': 221 data = self._do_save(req, attachment) 222 elif action == 'delete': 223 self._do_delete(req, attachment) 224 else: 225 raise HTTPBadRequest(_("Invalid request arguments.")) 226 elif action == 'delete': 227 data = self._render_confirm_delete(req, attachment) 228 elif action == 'new': 229 data = self._render_form(req, attachment) 230 else: 231 data = self._render_view(req, attachment) 232 233 add_stylesheet(req, 'common/css/code.css') 234 return 'attachment.html', data, None
235 236 # IWikiSyntaxProvider methods 237
238 - def get_wiki_syntax(self):
239 return []
240 244 245 # Public methods 246
247 - def viewable_attachments(self, context):
248 """Return the list of viewable attachments in the given context. 249 250 :param context: the `~trac.mimeview.api.RenderingContext` 251 corresponding to the parent 252 `~trac.resource.Resource` for the attachments 253 """ 254 parent = context.resource 255 attachments = [] 256 for attachment in Attachment.select(self.env, parent.realm, parent.id): 257 if 'ATTACHMENT_VIEW' in context.perm(attachment.resource): 258 attachments.append(attachment) 259 return attachments
260
261 - def attachment_data(self, context):
262 """Return a data dictionary describing the list of viewable 263 attachments in the current context. 264 """ 265 attachments = self.viewable_attachments(context) 266 parent = context.resource 267 total_size = sum(attachment.size for attachment in attachments) 268 new_att = parent.child(self.realm) 269 return {'attach_href': get_resource_url(self.env, new_att, 270 context.href), 271 'download_href': get_resource_url(self.env, new_att, 272 context.href, format='zip') 273 if total_size <= self.max_zip_size else None, 274 'can_create': 'ATTACHMENT_CREATE' in context.perm(new_att), 275 'attachments': attachments, 276 'parent': context.resource}
277
278 - def get_history(self, start, stop, realm):
279 """Return an iterable of tuples describing changes to attachments on 280 a particular object realm. 281 282 The tuples are in the form (change, realm, id, filename, time, 283 description, author). `change` can currently only be `created`. 284 285 FIXME: no iterator 286 """ 287 for realm, id, filename, ts, description, author in \ 288 self.env.db_query(""" 289 SELECT type, id, filename, time, description, author 290 FROM attachment WHERE time > %s AND time < %s AND type = %s 291 """, (to_utimestamp(start), to_utimestamp(stop), realm)): 292 time = from_utimestamp(ts or 0) 293 yield ('created', realm, id, filename, time, description, author)
294
295 - def get_timeline_events(self, req, resource_realm, start, stop):
296 """Return an event generator suitable for ITimelineEventProvider. 297 298 Events are changes to attachments on resources of the given 299 `resource_realm.realm`. 300 """ 301 for change, realm, id, filename, time, descr, author in \ 302 self.get_history(start, stop, resource_realm.realm): 303 attachment = resource_realm(id=id).child(self.realm, filename) 304 if 'ATTACHMENT_VIEW' in req.perm(attachment): 305 yield ('attachment', time, author, (attachment, descr), self)
306
307 - def render_timeline_event(self, context, field, event):
308 attachment, descr = event[3] 309 if field == 'url': 310 return self.get_resource_url(attachment, context.href) 311 elif field == 'title': 312 name = get_resource_name(self.env, attachment.parent) 313 title = get_resource_summary(self.env, attachment.parent) 314 return tag_("%(attachment)s attached to %(resource)s", 315 attachment=tag.em(os.path.basename(attachment.id)), 316 resource=tag.em(name, title=title)) 317 elif field == 'description': 318 return format_to(self.env, None, context.child(attachment.parent), 319 descr)
320
321 - def get_search_results(self, req, resource_realm, terms):
322 """Return a search result generator suitable for ISearchSource. 323 324 Search results are attachments on resources of the given 325 `resource_realm.realm` whose filename, description or author match 326 the given terms. 327 """ 328 with self.env.db_query as db: 329 sql_query, args = search_to_sql( 330 db, ['filename', 'description', 'author'], terms) 331 for id, time, filename, desc, author in db(""" 332 SELECT id, time, filename, description, author 333 FROM attachment WHERE type = %s AND """ + sql_query, 334 (resource_realm.realm,) + args): 335 attachment = resource_realm(id=id).child(self.realm, filename) 336 if 'ATTACHMENT_VIEW' in req.perm(attachment): 337 yield (get_resource_url(self.env, attachment, req.href), 338 get_resource_shortname(self.env, attachment), 339 from_utimestamp(time), author, 340 shorten_result(desc, terms))
341 342 # IResourceManager methods 343
344 - def get_resource_realms(self):
345 yield self.realm
346
347 - def get_resource_url(self, resource, href, **kwargs):
348 """Return an URL to the attachment itself. 349 350 A `format` keyword argument equal to `'raw'` will be converted 351 to the raw-attachment prefix. 352 """ 353 if not resource.parent: 354 return None 355 format = kwargs.get('format') 356 prefix = 'attachment' 357 if format in ('raw', 'zip'): 358 kwargs.pop('format') 359 prefix = format + '-attachment' 360 parent_href = unicode_unquote(get_resource_url(self.env, 361 resource.parent(version=None), Href(''))) 362 if not resource.id: 363 # link to list of attachments, which must end with a trailing '/' 364 # (see process_request) 365 return href(prefix, parent_href, '', **kwargs) 366 else: 367 return href(prefix, parent_href, resource.id, **kwargs)
368
369 - def get_resource_description(self, resource, format=None, **kwargs):
370 if not resource.parent: 371 return _("Unparented attachment %(id)s", id=resource.id) 372 if format == 'compact': 373 return '%s (%s)' % (resource.id, 374 get_resource_name(self.env, resource.parent)) 375 elif format == 'summary': 376 return Attachment(self.env, resource).description 377 if resource.id: 378 return _("Attachment '%(id)s' in %(parent)s", id=resource.id, 379 parent=get_resource_name(self.env, resource.parent)) 380 else: 381 return _("Attachments of %(parent)s", 382 parent=get_resource_name(self.env, resource.parent))
383
384 - def resource_exists(self, resource):
385 try: 386 attachment = Attachment(self.env, resource) 387 return os.path.exists(attachment.path) 388 except ResourceNotFound: 389 return False
390 391 # Internal methods 392
393 - def _do_save(self, req, attachment):
394 req.perm(attachment.resource).require('ATTACHMENT_CREATE') 395 parent_resource = attachment.resource.parent 396 397 if 'cancel' in req.args: 398 req.redirect(get_resource_url(self.env, parent_resource, req.href)) 399 400 upload = req.args.getfirst('attachment') 401 if not hasattr(upload, 'filename') or not upload.filename: 402 raise TracError(_("No file uploaded")) 403 if hasattr(upload.file, 'fileno'): 404 size = os.fstat(upload.file.fileno())[6] 405 else: 406 upload.file.seek(0, 2) # seek to end of file 407 size = upload.file.tell() 408 upload.file.seek(0) 409 if size == 0: 410 raise TracError(_("Can't upload empty file")) 411 412 # Maximum attachment size (in bytes) 413 max_size = self.max_size 414 if 0 <= max_size < size: 415 raise TracError(_("Maximum attachment size: %(num)s", 416 num=pretty_size(max_size)), _("Upload failed")) 417 418 filename = _normalized_filename(upload.filename) 419 if not filename: 420 raise TracError(_("No file uploaded")) 421 # Now the filename is known, update the attachment resource 422 attachment.filename = filename 423 attachment.description = req.args.get('description', '') 424 attachment.author = get_reporter_id(req, 'author') 425 attachment.ipnr = req.remote_addr 426 427 # Validate attachment 428 valid = True 429 for manipulator in self.manipulators: 430 for field, message in manipulator.validate_attachment(req, 431 attachment): 432 valid = False 433 if field: 434 add_warning(req, 435 tag_("Attachment field %(field)s is invalid: " 436 "%(message)s", field=tag.strong(field), 437 message=message)) 438 else: 439 add_warning(req, 440 tag_("Invalid attachment: %(message)s", 441 message=message)) 442 if not valid: 443 # Display the attach form with pre-existing data 444 # NOTE: Local file path not known, file field cannot be repopulated 445 add_warning(req, _('Note: File must be selected again.')) 446 data = self._render_form(req, attachment) 447 data['is_replace'] = req.args.get('replace') 448 return data 449 450 if req.args.get('replace'): 451 try: 452 old_attachment = Attachment(self.env, 453 attachment.resource(id=filename)) 454 if not (req.authname and req.authname != 'anonymous' 455 and old_attachment.author == req.authname) \ 456 and 'ATTACHMENT_DELETE' \ 457 not in req.perm(attachment.resource): 458 raise PermissionError(msg=_("You don't have permission to " 459 "replace the attachment %(name)s. You can only " 460 "replace your own attachments. Replacing other's " 461 "attachments requires ATTACHMENT_DELETE permission.", 462 name=filename)) 463 if (not attachment.description.strip() and 464 old_attachment.description): 465 attachment.description = old_attachment.description 466 old_attachment.delete() 467 except TracError: 468 pass # don't worry if there's nothing to replace 469 attachment.insert(filename, upload.file, size) 470 471 req.redirect(get_resource_url(self.env, attachment.resource(id=None), 472 req.href))
473
474 - def _do_delete(self, req, attachment):
475 req.perm(attachment.resource).require('ATTACHMENT_DELETE') 476 477 parent_href = get_resource_url(self.env, attachment.resource.parent, 478 req.href) 479 if 'cancel' in req.args: 480 req.redirect(parent_href) 481 482 attachment.delete() 483 req.redirect(parent_href)
484
485 - def _render_confirm_delete(self, req, attachment):
486 req.perm(attachment.resource).require('ATTACHMENT_DELETE') 487 return {'mode': 'delete', 488 'title': _('%(attachment)s (delete)', 489 attachment=get_resource_name(self.env, 490 attachment.resource)), 491 'attachment': attachment}
492
493 - def _render_form(self, req, attachment):
494 req.perm(attachment.resource).require('ATTACHMENT_CREATE') 495 return {'mode': 'new', 'author': get_reporter_id(req), 496 'attachment': attachment, 'max_size': self.max_size}
497
498 - def _download_as_zip(self, req, parent, attachments=None):
499 if attachments is None: 500 attachments = self.viewable_attachments(web_context(req, parent)) 501 total_size = sum(attachment.size for attachment in attachments) 502 if total_size > self.max_zip_size: 503 raise TracError(_("Maximum total attachment size: %(num)s", 504 num=pretty_size(self.max_zip_size)), _("Download failed")) 505 506 req.send_response(200) 507 req.send_header('Content-Type', 'application/zip') 508 filename = 'attachments-%s-%s.zip' % \ 509 (parent.realm, re.sub(r'[/\\:]', '-', unicode(parent.id))) 510 req.send_header('Content-Disposition', 511 content_disposition('inline', filename)) 512 req.end_headers() 513 514 def write_partial(fileobj, start): 515 end = fileobj.tell() 516 fileobj.seek(start, 0) 517 remaining = end - start 518 while remaining > 0: 519 chunk = fileobj.read(min(remaining, 4096)) 520 req.write(chunk) 521 remaining -= len(chunk) 522 fileobj.seek(end, 0) 523 return end
524 525 pos = 0 526 fileobj = TemporaryFile(prefix='trac-', suffix='.zip') 527 try: 528 zipfile = ZipFile(fileobj, 'w', ZIP_DEFLATED) 529 for attachment in attachments: 530 zipinfo = create_zipinfo(attachment.filename, 531 mtime=attachment.date, 532 comment=attachment.description) 533 try: 534 with attachment.open() as fd: 535 zipfile.writestr(zipinfo, fd.read()) 536 except ResourceNotFound: 537 pass # skip missing files 538 else: 539 pos = write_partial(fileobj, pos) 540 finally: 541 try: 542 zipfile.close() 543 write_partial(fileobj, pos) 544 finally: 545 fileobj.close() 546 raise RequestDone
547
548 - def _render_list(self, req, parent):
549 data = { 550 'mode': 'list', 551 'attachment': None, # no specific attachment 552 'attachments': self.attachment_data(web_context(req, parent)) 553 } 554 555 return 'attachment.html', data, None
556
557 - def _render_view(self, req, attachment):
558 req.perm(attachment.resource).require('ATTACHMENT_VIEW') 559 can_delete = 'ATTACHMENT_DELETE' in req.perm(attachment.resource) 560 req.check_modified(attachment.date, str(can_delete)) 561 562 data = {'mode': 'view', 563 'title': get_resource_name(self.env, attachment.resource), 564 'attachment': attachment} 565 566 with attachment.open() as fd: 567 mimeview = Mimeview(self.env) 568 569 # MIME type detection 570 str_data = fd.read(1000) 571 fd.seek(0) 572 573 mime_type = mimeview.get_mimetype(attachment.filename, str_data) 574 575 # Eventually send the file directly 576 format = req.args.get('format') 577 if format == 'zip': 578 self._download_as_zip(req, attachment.resource.parent, 579 [attachment]) 580 elif format in ('raw', 'txt'): 581 if not self.render_unsafe_content: 582 # Force browser to download files instead of rendering 583 # them, since they might contain malicious code enabling 584 # XSS attacks 585 req.send_header('Content-Disposition', 'attachment') 586 if format == 'txt': 587 mime_type = 'text/plain' 588 elif not mime_type: 589 mime_type = 'application/octet-stream' 590 if 'charset=' not in mime_type: 591 charset = mimeview.get_charset(str_data, mime_type) 592 mime_type = mime_type + '; charset=' + charset 593 req.send_file(attachment.path, mime_type) 594 595 # add ''Plain Text'' alternate link if needed 596 if (self.render_unsafe_content and 597 mime_type and not mime_type.startswith('text/plain')): 598 plaintext_href = get_resource_url(self.env, 599 attachment.resource, 600 req.href, format='txt') 601 add_link(req, 'alternate', plaintext_href, _('Plain Text'), 602 mime_type) 603 604 # add ''Original Format'' alternate link (always) 605 raw_href = get_resource_url(self.env, attachment.resource, 606 req.href, format='raw') 607 add_link(req, 'alternate', raw_href, _('Original Format'), 608 mime_type) 609 610 self.log.debug("Rendering preview of file %s with mime-type %s", 611 attachment.filename, mime_type) 612 613 data['preview'] = mimeview.preview_data( 614 web_context(req, attachment.resource), fd, 615 os.fstat(fd.fileno()).st_size, mime_type, 616 attachment.filename, raw_href, annotations=['lineno']) 617 return data
618 664
665 666 -class Attachment(object):
667 """Represents an attachment (new or existing). 668 669 :since 1.0.5: `ipnr` is deprecated and will be removed in 1.3.1 670 """ 671 672 realm = AttachmentModule.realm 673 674 @property
675 - def resource(self):
676 return Resource(self.parent_realm, self.parent_id) \ 677 .child(self.realm, self.filename)
678
679 - def __init__(self, env, parent_realm_or_attachment_resource, 680 parent_id=None, filename=None):
681 if isinstance(parent_realm_or_attachment_resource, Resource): 682 resource = parent_realm_or_attachment_resource 683 self.parent_realm = resource.parent.realm 684 self.parent_id = unicode(resource.parent.id) 685 self.filename = resource.id 686 else: 687 self.parent_realm = parent_realm_or_attachment_resource 688 self.parent_id = unicode(parent_id) 689 self.filename = filename 690 691 self.env = env 692 if self.filename: 693 self._fetch(self.filename) 694 else: 695 self.filename = None 696 self.description = None 697 self.size = None 698 self.date = None 699 self.author = None 700 self.ipnr = None
701
702 - def __repr__(self):
703 return '<%s %r>' % (self.__class__.__name__, self.filename)
704
705 - def _from_database(self, filename, description, size, time, author, ipnr):
706 self.filename = filename 707 self.description = description 708 self.size = int(size) if size else 0 709 self.date = from_utimestamp(time or 0) 710 self.author = author 711 self.ipnr = ipnr
712
713 - def _fetch(self, filename):
714 for row in self.env.db_query(""" 715 SELECT filename, description, size, time, author, ipnr 716 FROM attachment WHERE type=%s AND id=%s AND filename=%s 717 ORDER BY time 718 """, (self.parent_realm, unicode(self.parent_id), filename)): 719 self._from_database(*row) 720 break 721 else: 722 self.filename = filename 723 raise ResourceNotFound(_("Attachment '%(title)s' does not exist.", 724 title=self.title), 725 _('Invalid Attachment'))
726 727 # _get_path() and _get_hashed_filename() are class methods so that they 728 # can be used in db28.py. 729 730 @classmethod
731 - def _get_path(cls, env_path, parent_realm, parent_id, filename):
732 """Get the path of an attachment. 733 734 WARNING: This method is used by db28.py for moving attachments from 735 the old "attachments" directory to the "files" directory. Please check 736 all changes so that they don't break the upgrade. 737 """ 738 path = os.path.join(env_path, 'files', 'attachments', 739 parent_realm) 740 hash = hashlib.sha1(parent_id.encode('utf-8')).hexdigest() 741 path = os.path.join(path, hash[0:3], hash) 742 if filename: 743 path = os.path.join(path, cls._get_hashed_filename(filename)) 744 return os.path.normpath(path)
745 746 _extension_re = re.compile(r'\.[A-Za-z0-9]+\Z') 747 748 @classmethod
749 - def _get_hashed_filename(cls, filename):
750 """Get the hashed filename corresponding to the given filename. 751 752 WARNING: This method is used by db28.py for moving attachments from 753 the old "attachments" directory to the "files" directory. Please check 754 all changes so that they don't break the upgrade. 755 """ 756 hash = hashlib.sha1(filename.encode('utf-8')).hexdigest() 757 match = cls._extension_re.search(filename) 758 return hash + match.group(0) if match else hash
759 760 @property
761 - def path(self):
762 return self._get_path(self.env.path, self.parent_realm, self.parent_id, 763 self.filename)
764 765 @property
766 - def title(self):
767 return '%s:%s: %s' % (self.parent_realm, self.parent_id, self.filename)
768
769 - def delete(self):
770 """Delete the attachment, both the record in the database and 771 the file itself. 772 """ 773 assert self.filename, "Cannot delete non-existent attachment" 774 775 with self.env.db_transaction as db: 776 db(""" 777 DELETE FROM attachment WHERE type=%s AND id=%s AND filename=%s 778 """, (self.parent_realm, self.parent_id, self.filename)) 779 path = self.path 780 if os.path.isfile(path): 781 try: 782 os.unlink(path) 783 except OSError as e: 784 self.env.log.error("Failed to delete attachment " 785 "file %s: %s", 786 path, 787 exception_to_unicode(e, traceback=True)) 788 raise TracError(_("Could not delete attachment")) 789 790 self.env.log.info("Attachment removed: %s", self.title) 791 792 for listener in AttachmentModule(self.env).change_listeners: 793 listener.attachment_deleted(self)
794
795 - def reparent(self, new_realm, new_id):
796 assert self.filename, "Cannot reparent non-existent attachment" 797 new_id = unicode(new_id) 798 new_path = self._get_path(self.env.path, new_realm, new_id, 799 self.filename) 800 801 # Make sure the path to the attachment is inside the environment 802 # attachments directory 803 attachments_dir = os.path.join(os.path.normpath(self.env.path), 804 'files', 'attachments') 805 commonprefix = os.path.commonprefix([attachments_dir, new_path]) 806 if commonprefix != attachments_dir: 807 raise TracError(_('Cannot reparent attachment "%(att)s" as ' 808 '%(realm)s:%(id)s is invalid', 809 att=self.filename, realm=new_realm, id=new_id)) 810 811 if os.path.exists(new_path): 812 raise TracError(_('Cannot reparent attachment "%(att)s" as ' 813 'it already exists in %(realm)s:%(id)s', 814 att=self.filename, realm=new_realm, id=new_id)) 815 with self.env.db_transaction as db: 816 db("""UPDATE attachment SET type=%s, id=%s 817 WHERE type=%s AND id=%s AND filename=%s 818 """, (new_realm, new_id, self.parent_realm, self.parent_id, 819 self.filename)) 820 dirname = os.path.dirname(new_path) 821 if not os.path.exists(dirname): 822 os.makedirs(dirname) 823 path = self.path 824 if os.path.isfile(path): 825 try: 826 os.rename(path, new_path) 827 except OSError as e: 828 self.env.log.error("Failed to move attachment file %s: %s", 829 path, 830 exception_to_unicode(e, traceback=True)) 831 raise TracError(_("Could not reparent attachment %(name)s", 832 name=self.filename)) 833 834 old_realm, old_id = self.parent_realm, self.parent_id 835 self.parent_realm, self.parent_id = new_realm, new_id 836 837 self.env.log.info("Attachment reparented: %s", self.title) 838 839 for listener in AttachmentModule(self.env).change_listeners: 840 if hasattr(listener, 'attachment_reparented'): 841 listener.attachment_reparented(self, old_realm, old_id)
842
843 - def insert(self, filename, fileobj, size, t=None):
844 """Create a new Attachment record and save the file content. 845 """ 846 self.size = int(size) if size else 0 847 self.filename = None 848 if t is None: 849 t = datetime_now(utc) 850 elif not isinstance(t, datetime): # Compatibility with 0.11 851 t = to_datetime(t, utc) 852 self.date = t 853 854 parent_resource = Resource(self.parent_realm, self.parent_id) 855 if not resource_exists(self.env, parent_resource): 856 raise ResourceNotFound( 857 _("%(parent)s doesn't exist, can't create attachment", 858 parent=get_resource_name(self.env, parent_resource))) 859 860 # Make sure the path to the attachment is inside the environment 861 # attachments directory 862 attachments_dir = os.path.join(os.path.normpath(self.env.path), 863 'files', 'attachments') 864 dir = self.path 865 commonprefix = os.path.commonprefix([attachments_dir, dir]) 866 if commonprefix != attachments_dir: 867 raise TracError(_('Cannot create attachment "%(att)s" as ' 868 '%(realm)s:%(id)s is invalid', 869 att=filename, realm=self.parent_realm, 870 id=self.parent_id)) 871 872 if not os.access(dir, os.F_OK): 873 os.makedirs(dir) 874 filename, targetfile = self._create_unique_file(dir, filename) 875 with targetfile: 876 with self.env.db_transaction as db: 877 db("INSERT INTO attachment VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", 878 (self.parent_realm, self.parent_id, filename, self.size, 879 to_utimestamp(t), self.description, self.author, 880 self.ipnr)) 881 shutil.copyfileobj(fileobj, targetfile) 882 self.filename = filename 883 884 self.env.log.info("New attachment: %s by %s", self.title, 885 self.author) 886 887 for listener in AttachmentModule(self.env).change_listeners: 888 listener.attachment_added(self)
889 890 @classmethod
891 - def select(cls, env, parent_realm, parent_id):
892 """Iterator yielding all `Attachment` instances attached to 893 resource identified by `parent_realm` and `parent_id`. 894 895 :return: a tuple containing the `filename`, `description`, `size`, 896 `time`, `author` and `ipnr`. 897 :since 1.0.5: use of `ipnr` is deprecated and will be removed in 1.3.1 898 """ 899 for row in env.db_query(""" 900 SELECT filename, description, size, time, author, ipnr 901 FROM attachment WHERE type=%s AND id=%s ORDER BY time 902 """, (parent_realm, unicode(parent_id))): 903 attachment = Attachment(env, parent_realm, parent_id) 904 attachment._from_database(*row) 905 yield attachment
906 907 @classmethod
908 - def delete_all(cls, env, parent_realm, parent_id):
909 """Delete all attachments of a given resource. 910 """ 911 attachment_dir = None 912 with env.db_transaction as db: 913 for attachment in cls.select(env, parent_realm, parent_id): 914 attachment_dir = os.path.dirname(attachment.path) 915 attachment.delete() 916 if attachment_dir: 917 try: 918 os.rmdir(attachment_dir) 919 except OSError as e: 920 env.log.error("Can't delete attachment directory %s: %s", 921 attachment_dir, 922 exception_to_unicode(e, traceback=True))
923 924 @classmethod
925 - def reparent_all(cls, env, parent_realm, parent_id, new_realm, new_id):
926 """Reparent all attachments of a given resource to another resource.""" 927 attachment_dir = None 928 with env.db_transaction as db: 929 for attachment in list(cls.select(env, parent_realm, parent_id)): 930 attachment_dir = os.path.dirname(attachment.path) 931 attachment.reparent(new_realm, new_id) 932 if attachment_dir: 933 try: 934 os.rmdir(attachment_dir) 935 except OSError as e: 936 env.log.error("Can't delete attachment directory %s: %s", 937 attachment_dir, 938 exception_to_unicode(e, traceback=True))
939
940 - def open(self):
941 path = self.path 942 self.env.log.debug('Trying to open attachment at %s', path) 943 try: 944 fd = open(path, 'rb') 945 except IOError: 946 raise ResourceNotFound(_("Attachment '%(filename)s' not found", 947 filename=self.filename)) 948 return fd
949
950 - def _create_unique_file(self, dir, filename):
951 parts = os.path.splitext(filename) 952 flags = os.O_CREAT + os.O_WRONLY + os.O_EXCL 953 if hasattr(os, 'O_BINARY'): 954 flags += os.O_BINARY 955 idx = 1 956 while 1: 957 path = os.path.join(dir, self._get_hashed_filename(filename)) 958 try: 959 return filename, os.fdopen(os.open(path, flags, 0666), 'w') 960 except OSError as e: 961 if e.errno != errno.EEXIST: 962 raise 963 idx += 1 964 # A sanity check 965 if idx > 100: 966 raise Exception('Failed to create unique name: ' + path) 967 filename = '%s.%d%s' % (parts[0], idx, parts[1])
968
969 970 -class LegacyAttachmentPolicy(Component):
971 972 implements(IPermissionPolicy) 973 974 delegates = ExtensionPoint(ILegacyAttachmentPolicyDelegate) 975 976 realm = AttachmentModule.realm 977 978 # IPermissionPolicy methods 979 980 _perm_maps = { 981 'ATTACHMENT_CREATE': {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY', 982 'milestone': 'MILESTONE_MODIFY'}, 983 'ATTACHMENT_VIEW': {'ticket': 'TICKET_VIEW', 'wiki': 'WIKI_VIEW', 984 'milestone': 'MILESTONE_VIEW'}, 985 'ATTACHMENT_DELETE': {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE', 986 'milestone': 'MILESTONE_DELETE'}, 987 } 988
989 - def check_permission(self, action, username, resource, perm):
990 perm_map = self._perm_maps.get(action) 991 if not perm_map or not resource or resource.realm != self.realm: 992 return 993 legacy_action = perm_map.get(resource.parent.realm) 994 if legacy_action: 995 decision = legacy_action in perm(resource.parent) 996 if not decision: 997 self.log.debug('LegacyAttachmentPolicy denied %s access to ' 998 '%s. User needs %s', 999 username, resource, legacy_action) 1000 return decision 1001 else: 1002 for d in self.delegates: 1003 decision = d.check_attachment_permission(action, username, 1004 resource, perm) 1005 if decision is not None: 1006 return decision
1007
1008 1009 -class AttachmentAdmin(Component):
1010 """trac-admin command provider for attachment administration.""" 1011 1012 implements(IAdminCommandProvider) 1013 1014 # IAdminCommandProvider methods 1015
1016 - def get_admin_commands(self):
1017 yield ('attachment list', '<realm:id>', 1018 """List attachments of a resource 1019 1020 The resource is identified by its realm and identifier.""", 1021 self._complete_list, self._do_list) 1022 yield ('attachment add', '<realm:id> <path> [author] [description]', 1023 """Attach a file to a resource 1024 1025 The resource is identified by its realm and identifier. The 1026 attachment will be named according to the base name of the file. 1027 """, 1028 self._complete_add, self._do_add) 1029 yield ('attachment remove', '<realm:id> <name>', 1030 """Remove an attachment from a resource 1031 1032 The resource is identified by its realm and identifier.""", 1033 self._complete_remove, self._do_remove) 1034 yield ('attachment export', '<realm:id> <name> [destination]', 1035 """Export an attachment from a resource to a file or stdout 1036 1037 The resource is identified by its realm and identifier. If no 1038 destination is specified, the attachment is output to stdout. 1039 """, 1040 self._complete_export, self._do_export)
1041
1042 - def get_realm_list(self):
1043 rs = ResourceSystem(self.env) 1044 return PrefixList([each + ":" for each in rs.get_known_realms()])
1045
1046 - def split_resource(self, resource):
1047 result = resource.split(':', 1) 1048 if len(result) != 2: 1049 raise AdminCommandError(_("Invalid resource identifier '%(id)s'", 1050 id=resource)) 1051 return result
1052
1053 - def get_attachment_list(self, resource):
1054 (realm, id) = self.split_resource(resource) 1055 return [a.filename for a in Attachment.select(self.env, realm, id)]
1056
1057 - def _complete_list(self, args):
1058 if len(args) == 1: 1059 return self.get_realm_list()
1060
1061 - def _complete_add(self, args):
1062 if len(args) == 1: 1063 return self.get_realm_list() 1064 elif len(args) == 2: 1065 return get_dir_list(args[1])
1066
1067 - def _complete_remove(self, args):
1068 if len(args) == 1: 1069 return self.get_realm_list() 1070 elif len(args) == 2: 1071 return self.get_attachment_list(args[0])
1072
1073 - def _complete_export(self, args):
1074 if len(args) < 3: 1075 return self._complete_remove(args) 1076 elif len(args) == 3: 1077 return get_dir_list(args[2])
1078
1079 - def _do_list(self, resource):
1080 (realm, id) = self.split_resource(resource) 1081 print_table([(a.filename, pretty_size(a.size), a.author, 1082 format_datetime(a.date, console_datetime_format), 1083 a.description) 1084 for a in Attachment.select(self.env, realm, id)], 1085 [_('Name'), _('Size'), _('Author'), _('Date'), 1086 _('Description')])
1087
1088 - def _do_add(self, resource, path, author='trac', description=''):
1089 (realm, id) = self.split_resource(resource) 1090 attachment = Attachment(self.env, realm, id) 1091 attachment.author = author 1092 attachment.description = description 1093 filename = _normalized_filename(os.path.basename(path)) 1094 with open(path, 'rb') as f: 1095 attachment.insert(filename, f, os.path.getsize(path))
1096
1097 - def _do_remove(self, resource, name):
1101
1102 - def _do_export(self, resource, name, destination=None):
1103 (realm, id) = self.split_resource(resource) 1104 attachment = Attachment(self.env, realm, id, name) 1105 if destination is not None: 1106 if os.path.isdir(destination): 1107 destination = os.path.join(destination, name) 1108 if os.path.isfile(destination): 1109 raise AdminCommandError(_("File '%(name)s' exists", 1110 name=path_to_unicode(destination))) 1111 with attachment.open() as input: 1112 output = open(destination, "wb") if destination is not None \ 1113 else sys.stdout 1114 try: 1115 shutil.copyfileobj(input, output) 1116 finally: 1117 if destination is not None: 1118 output.close()
1119 1120 1121 _control_codes_re = re.compile( 1122 '[' + 1123 ''.join(filter(lambda c: unicodedata.category(c) == 'Cc', 1124 map(unichr, xrange(0x10000)))) + 1125 ']')
1126 1127 -def _normalized_filename(filepath):
1128 # We try to normalize the filename to unicode NFC if we can. 1129 # Files uploaded from OS X might be in NFD. 1130 if not isinstance(filepath, unicode): 1131 filepath = unicode(filepath, 'utf-8') 1132 filepath = unicodedata.normalize('NFC', filepath) 1133 # Replace control codes with spaces, e.g. NUL, LF, DEL, U+009F 1134 filepath = _control_codes_re.sub(' ', filepath) 1135 # Replace backslashes with slashes if filename is Windows full path 1136 if filepath.startswith('\\') or re.match(r'[A-Za-z]:\\', filepath): 1137 filepath = filepath.replace('\\', '/') 1138 # We want basename to be delimited by only slashes on all platforms 1139 filename = posixpath.basename(filepath) 1140 filename = stripws(filename) 1141 return filename
1142