1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
56 """Exception raised when attachment validation fails."""
57
60 """Extension point interface for components that require
61 notification when attachments are created or deleted."""
62
64 """Called when an attachment is added."""
65
67 """Called when an attachment is deleted."""
68
70 """Called when an attachment is reparented."""
71
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
81 """Not currently called, but should be provided for future
82 compatibility."""
83
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
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
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
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
154
157
160
161
162
174
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
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:
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
237
240
242 yield ('raw-attachment', self._format_link)
243 yield ('attachment', self._format_link)
244
245
246
260
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
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
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
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
343
346
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
364
365 return href(prefix, parent_href, '', **kwargs)
366 else:
367 return href(prefix, parent_href, resource.id, **kwargs)
368
383
390
391
392
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)
407 size = upload.file.tell()
408 upload.file.seek(0)
409 if size == 0:
410 raise TracError(_("Can't upload empty file"))
411
412
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
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
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
444
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
469 attachment.insert(filename, upload.file, size)
470
471 req.redirect(get_resource_url(self.env, attachment.resource(id=None),
472 req.href))
473
484
492
497
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
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
556
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
570 str_data = fd.read(1000)
571 fd.seek(0)
572
573 mime_type = mimeview.get_mimetype(attachment.filename, str_data)
574
575
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
583
584
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
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
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
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
678
679 - def __init__(self, env, parent_realm_or_attachment_resource,
680 parent_id=None, filename=None):
701
703 return '<%s %r>' % (self.__class__.__name__, self.filename)
704
705 - def _from_database(self, filename, description, size, time, author, ipnr):
712
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
728
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
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
762 return self._get_path(self.env.path, self.parent_realm, self.parent_id,
763 self.filename)
764
765 @property
767 return '%s:%s: %s' % (self.parent_realm, self.parent_id, self.filename)
768
794
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
802
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):
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
861
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):
923
924 @classmethod
925 - def reparent_all(cls, env, parent_realm, parent_id, new_realm, new_id):
939
949
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
965 if idx > 100:
966 raise Exception('Failed to create unique name: ' + path)
967 filename = '%s.%d%s' % (parts[0], idx, parts[1])
968
1007
1010 """trac-admin command provider for attachment administration."""
1011
1012 implements(IAdminCommandProvider)
1013
1014
1015
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
1045
1052
1056
1060
1066
1072
1078
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=''):
1096
1101
1102 - def _do_export(self, resource, name, destination=None):
1119
1120
1121 _control_codes_re = re.compile(
1122 '[' +
1123 ''.join(filter(lambda c: unicodedata.category(c) == 'Cc',
1124 map(unichr, xrange(0x10000)))) +
1125 ']')
1128
1129
1130 if not isinstance(filepath, unicode):
1131 filepath = unicode(filepath, 'utf-8')
1132 filepath = unicodedata.normalize('NFC', filepath)
1133
1134 filepath = _control_codes_re.sub(' ', filepath)
1135
1136 if filepath.startswith('\\') or re.match(r'[A-Za-z]:\\', filepath):
1137 filepath = filepath.replace('\\', '/')
1138
1139 filename = posixpath.basename(filepath)
1140 filename = stripws(filename)
1141 return filename
1142