Edgewall Software

TracDev/ContextRefactoring: trac_attachment.py

File trac_attachment.py, 27.5 KB (added by cboos, 16 months ago)

Work in progress snapshot - this illustrates the modifications need to use the new resource and permission scheme (base is source:trunk/trac/attachment.py)

Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2005 Edgewall Software
4# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
5# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
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 http://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 http://trac.edgewall.org/log/.
15#
16# Author: Jonas Borgström <jonas@edgewall.com>
17#         Christopher Lenz <cmlenz@gmx.de>
18
19from datetime import datetime
20import os
21import re
22import shutil
23import time
24import unicodedata
25
26from genshi.builder import tag
27
28from trac import perm, util
29from trac.config import BoolOption, IntOption
30from trac.context import IResourceManager, ResourceSystem, ResourceNotFound
31from trac.core import *
32from trac.env import IEnvironmentSetupParticipant
33from trac.perm import PermissionError, PermissionSystem, IPermissionPolicy
34from trac.mimeview import *
35from trac.timeline.api import TimelineEvent
36from trac.util import get_reporter_id, create_unique_file, content_disposition
37from trac.util.datefmt import to_timestamp, utc
38from trac.util.text import unicode_quote, unicode_unquote, pretty_size
39from trac.util.translation import _
40from trac.web import HTTPBadRequest, IRequestHandler
41from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
42from trac.wiki.api import IWikiSyntaxProvider
43
44
45class InvalidAttachment(TracError):
46    """Exception raised when attachment validation fails."""
47
48
49class IAttachmentChangeListener(Interface):
50    """Extension point interface for components that require notification when
51    attachments are created or deleted."""
52
53    def attachment_added(attachment):
54        """Called when an attachment is added."""
55
56    def attachment_deleted(attachment):
57        """Called when an attachment is deleted."""
58
59
60class IAttachmentManipulator(Interface):
61    """Extension point interface for components that need to manipulate
62    attachments.
63   
64    Unlike change listeners, a manipulator can reject changes being committed
65    to the database."""
66
67    def prepare_attachment(req, attachment, fields):
68        """Not currently called, but should be provided for future
69        compatibility."""
70
71    def validate_attachment(req, attachment):
72        """Validate an attachment after upload but before being stored in Trac
73        environment.
74       
75        Must return a list of `(field, message)` tuples, one for each problem
76        detected. `field` can be any of `description`, `username`, `filename`,
77        `content`, or `None` to indicate an overall problem with the
78        attachment. Therefore, a return value of `[]` means everything is
79        OK."""
80
81
82
83class AttachmentList(object):
84    """Helper class for template data, representing a list of attachments."""
85   
86    def __init__(self, parent, attachments):
87        self.attachments = filter(None, [parent.child('attachment', a.filename,
88                                                      model=a)
89                                         for a in attachments])
90        self.new_attachment = parent.child('attachment')
91
92    def attach_href(self, href):
93        return self.new_attachment.url(href, action='new')
94
95    def can_create(self):
96        return 'ATTACHMENT_CREATE' in self.new_attachment.perm
97
98
99class Attachment(object):
100
101    def __init__(self, env, parent_realm, parent_id, filename=None, db=None):
102        self.env = env
103        self.parent_realm = parent_realm
104        self.parent_id = unicode(parent_id)
105        if filename:
106            self._fetch(filename, db)
107        else:
108            self.filename = None
109            self.description = None
110            self.size = None
111            self.date = None
112            self.author = None
113            self.ipnr = None
114
115    def _fetch(self, filename, db=None):
116        if not db:
117            db = self.env.get_db_cnx()
118        cursor = db.cursor()
119        cursor.execute("SELECT filename,description,size,time,author,ipnr "
120                       "FROM attachment WHERE type=%s AND id=%s "
121                       "AND filename=%s ORDER BY time",
122                       (self.parent_realm, unicode(self.parent_id), filename))
123        row = cursor.fetchone()
124        cursor.close()
125        if not row:
126            self.filename = filename
127            raise ResourceNotFound(_("Attachment '%(title)s' does not exist.",
128                                     title=self.title), _('Invalid Attachment'))
129        self.filename = row[0]
130        self.description = row[1]
131        self.size = row[2] and int(row[2]) or 0
132        time = row[3] and int(row[3]) or 0
133        self.date = datetime.fromtimestamp(time, utc)
134        self.author = row[4]
135        self.ipnr = row[5]
136
137    def _get_path(self):
138        path = os.path.join(self.env.path, 'attachments', self.parent_realm,
139                            unicode_quote(self.parent_id))
140        if self.filename:
141            path = os.path.join(path, unicode_quote(self.filename))
142        return os.path.normpath(path)
143    path = property(_get_path)
144
145    def _get_title(self):
146        return '%s:%s: %s' % (self.parent_realm, 
147                              self.parent_id, self.filename)
148    title = property(_get_title)
149
150    def delete(self, db=None):
151        assert self.filename, 'Cannot delete non-existent attachment'
152        if not db:
153            db = self.env.get_db_cnx()
154            handle_ta = True
155        else:
156            handle_ta = False
157
158        cursor = db.cursor()
159        cursor.execute("DELETE FROM attachment WHERE type=%s AND id=%s "
160                       "AND filename=%s", (self.parent_realm, self.parent_id,
161                       self.filename))
162        if os.path.isfile(self.path):
163            try:
164                os.unlink(self.path)
165            except OSError:
166                self.env.log.error('Failed to delete attachment file %s',
167                                   self.path, exc_info=True)
168                if handle_ta:
169                    db.rollback()
170                raise TracError(_('Could not delete attachment'))
171
172        self.env.log.info('Attachment removed: %s' % self.title)
173        if handle_ta:
174            db.commit()
175
176        for listener in AttachmentModule(self.env).change_listeners:
177            listener.attachment_deleted(self)
178
179
180    def insert(self, filename, fileobj, size, t=None, db=None):
181        # FIXME: `t` should probably be switched to `datetime` too
182        if not db:
183            db = self.env.get_db_cnx()
184            handle_ta = True
185        else:
186            handle_ta = False
187
188        self.size = size and int(size) or 0
189        timestamp = int(t or time.time())
190        self.date = datetime.fromtimestamp(timestamp, utc)
191
192        # Make sure the path to the attachment is inside the environment
193        # attachments directory
194        attachments_dir = os.path.join(os.path.normpath(self.env.path),
195                                       'attachments')
196        commonprefix = os.path.commonprefix([attachments_dir, self.path])
197        assert commonprefix == attachments_dir
198
199        if not os.access(self.path, os.F_OK):
200            os.makedirs(self.path)
201        filename = unicode_quote(filename)
202        path, targetfile = create_unique_file(os.path.join(self.path,
203                                                           filename))
204        try:
205            # Note: `path` is an unicode string because `self.path` was one.
206            # As it contains only quoted chars and numbers, we can use `ascii`
207            basename = os.path.basename(path).encode('ascii')
208            filename = unicode_unquote(basename)
209
210            cursor = db.cursor()
211            cursor.execute("INSERT INTO attachment "
212                           "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
213                           (self.parent_realm, self.parent_id, filename,
214                            self.size, timestamp, self.description,
215                            self.author, self.ipnr))
216            shutil.copyfileobj(fileobj, targetfile)
217            self.filename = filename
218
219            self.env.log.info('New attachment: %s by %s', self.title,
220                              self.author)
221
222            if handle_ta:
223                db.commit()
224
225            for listener in AttachmentModule(self.env).change_listeners:
226                listener.attachment_added(self)
227
228        finally:
229            targetfile.close()
230
231    def select(cls, env, parent_realm, parent_id, db=None):
232        if not db:
233            db = env.get_db_cnx()
234        cursor = db.cursor()
235        cursor.execute("SELECT filename,description,size,time,author,ipnr "
236                       "FROM attachment WHERE type=%s AND id=%s ORDER BY time",
237                       (parent_realm, unicode(parent_id)))
238        for filename,description,size,time,author,ipnr in cursor:
239            attachment = Attachment(env, parent_realm, parent_id)
240            attachment.filename = filename
241            attachment.description = description
242            attachment.size = size and int(size) or 0
243            time = time and int(time) or 0
244            attachment.date = datetime.fromtimestamp(time, utc)
245            attachment.author = author
246            attachment.ipnr = ipnr
247            yield attachment
248
249    def delete_all(cls, env, parent_realm, parent_id, db):
250        """Delete all attachments of a given resource.
251
252        As this is usually done while deleting the parent resource,
253        the `db` argument is ''not'' optional here.
254        """
255        attachment_dir = None
256        for attachment in list(cls.select(env, parent_realm, parent_id, db)):
257            attachment_dir = os.path.dirname(attachment.path)
258            attachment.delete(db)
259        if attachment_dir:
260            try:
261                os.rmdir(attachment_dir)
262            except OSError:
263                env.log.error("Can't delete attachment directory %s",
264                              attachment_dir, exc_info=True)
265           
266    select = classmethod(select)
267    delete_all = classmethod(delete_all)
268
269    def open(self):
270        self.env.log.debug('Trying to open attachment at %s', self.path)
271        try:
272            fd = open(self.path, 'rb')
273        except IOError:
274            raise ResourceNotFound(_("Attachment '%(filename)s' not found",
275                                     filename=self.filename))
276        return fd
277
278
279class AttachmentModule(Component):
280
281    implements(IEnvironmentSetupParticipant, IRequestHandler,
282               INavigationContributor, IWikiSyntaxProvider, IResourceManager)
283
284    change_listeners = ExtensionPoint(IAttachmentChangeListener)
285    manipulators = ExtensionPoint(IAttachmentManipulator)
286
287    CHUNK_SIZE = 4096
288
289    max_size = IntOption('attachment', 'max_size', 262144,
290        """Maximum allowed file size for ticket and wiki attachments.""")
291
292    render_unsafe_content = BoolOption('attachment', 'render_unsafe_content',
293                                       'false',
294        """Whether attachments should be rendered in the browser, or
295        only made downloadable.
296
297        Pretty much any file may be interpreted as HTML by the browser,
298        which allows a malicious user to attach a file containing cross-site
299        scripting attacks.
300
301        For public sites where anonymous users can create attachments it is
302        recommended to leave this option disabled (which is the default).""")
303
304    # IEnvironmentSetupParticipant methods
305
306    def environment_created(self):
307        """Create the attachments directory."""
308        if self.env.path:
309            os.mkdir(os.path.join(self.env.path, 'attachments'))
310
311    def environment_needs_upgrade(self, db):
312        return False
313
314    def upgrade_environment(self, db):
315        pass
316
317    # INavigationContributor methods
318
319    def get_active_navigation_item(self, req):
320        return req.args.get('realm')
321
322    def get_navigation_items(self, req):
323        return []
324
325    # IRequestHandler methods
326
327    def match_request(self, req):
328        match = re.match(r'^/(raw-)?attachment/([^/]+)(?:[/:](.*))?$',
329                         req.path_info)
330        if match:
331            raw, realm, filename = match.groups()
332            if raw:
333                req.args['format'] = 'raw'
334            req.args['realm'] = realm
335            if filename:
336                req.args['path'] = filename.replace(':', '/')
337            return True
338
339    def process_request(self, req):
340        parent_id = None
341        parent_realm = req.args.get('realm')
342        path = req.args.get('path')
343        filename = None
344       
345        if not parent_realm or not path:
346            raise HTTPBadRequest(_('Bad request'))
347
348        parent_realm = req.perm(parent_realm)
349        action = req.args.get('action', 'view')
350        if action == 'new':
351            parent_id = path
352        else:
353            segments = path.split('/')
354            parent_id = '/'.join(segments[:-1])
355            filename = len(segments) > 1 and segments[-1]
356            if not filename: # if there's a trailing '/', show the list
357                return self._render_list(
358                    req, parent_realm.assert_copy(id=parent_id))
359
360        parent = parent_realm.assert_copy(id=parent_id)
361        attachment = parent.child('attachment', filename)
362       
363        add_link(req, 'up', parent.url(req.href), parent.name)
364       
365        if req.method == 'POST':
366            if action == 'new':
367                self._do_save(req, attachment)
368            elif action == 'delete':
369                self._do_delete(req, attachment)
370        elif action == 'delete':
371            data = self._render_confirm_delete(req, attachment)
372        elif action == 'new':
373            data = self._render_form(req, attachment)
374        else:
375            data = self._render_view(req, attachment)
376
377        data['resource'] = attachment
378
379        add_stylesheet(req, 'common/css/code.css')
380        return 'attachment.html', data, None
381
382    # IWikiSyntaxProvider methods
383   
384    def get_wiki_syntax(self):
385        return []
386
387    def get_link_resolvers(self):
388        yield ('raw-attachment', self._format_link)
389        yield ('attachment', self._format_link)
390
391    # Public methods
392
393    def permitted_attachment_list(self, req, parent):
394        """Return list of attachments allowed in the request."""
395        return AttachmentList(parent,
396            [a for a in Attachment.select(self.env, parent.realm, parent.id)
397             if parent.child('attachment', a.filename)])
398
399    def get_history(self, start, stop, realm):
400        """Return an iterable of tuples describing changes to attachments on
401        a particular object realm.
402
403        The tuples are in the form (change, realm, id, filename, time,
404        description, author). `change` can currently only be `created`.
405        """
406        # Traverse attachment directory
407        db = self.env.get_db_cnx()
408        cursor = db.cursor()
409        cursor.execute("SELECT type, id, filename, time, description, author "
410                       "  FROM attachment "
411                       "  WHERE time > %s AND time < %s "
412                       "        AND type = %s",
413                       (to_timestamp(start), to_timestamp(stop), realm))
414        for realm, id, filename, ts, description, author in cursor:
415            time = datetime.fromtimestamp(ts, utc)
416            yield ('created', realm, id, filename, time, description, author)
417
418    def get_timeline_events(self, req, resource_realm, start, stop):
419        """Return an event generator suitable for ITimelineEventProvider.
420
421        Events are changes to attachments on resources of the given
422        `resource_realm.realm`.
423        """
424        for change, realm, id, filename, time, descr, author in \
425                self.get_history(start, stop, resource_realm.realm):
426            parent = resource_realm(id=id)
427            if parent:
428                attachment = parent.child('attachment', filename)
429                if attachment:
430                    title = tag(tag.em(os.path.basename(filename)),
431                                _(" attached to "),
432                                tag.em(parent.name, title=parent.summary))
433                    event = TimelineEvent(self, 'attachment')
434                    event.set_changeinfo(time, author)
435                    event.add_markup(title=title)
436                    event.add_wiki(parent, body=descr)
437                    yield event
438
439    def event_formatter(self, event, key):
440        return None
441   
442    # IResourceManager methods
443   
444    def get_resource_realms(self):
445        yield 'attachment'
446
447    def get_model(self, resource):
448        return Attachment(resource.env, resource.parent.realm,
449                          resource.parent.id, filename=resource.id)
450
451    def get_resource_url(self, resource, href, path=None, **kwargs):
452        """Return an URL to the attachment itself.
453
454        A `format` keyword argument equal to `'raw'` will be converted
455        to the raw-attachment prefix.
456        """
457        format = kwargs.get('format')
458        prefix = 'attachment'
459        if format == 'raw':
460            kwargs.pop('format')
461            prefix = 'raw-attachment'
462        path = [unicode(p) for p in
463                [prefix, resource.parent.realm, resource.parent.id,
464                 resource.id, path] if p]
465        return ResourceSystem(self.env).get_resource_url(
466            resource, href, '/' + '/'.join(path), **kwargs)
467
468    def get_resource_description(self, resource, format=None):
469        if format == 'compact':
470            return '%s:%s' % (resource.parent.shortname, resource.filename)
471        elif format == 'summary':
472            return resource.model.description
473        if resource.id:
474            return _("Attachment '%(id)s' in %(parent)s", id=resource.id,
475                     parent=resource.parent.name)
476        else:
477            return _("Attachments of %(parent)s", parent=resource.parent.name)
478
479    # Internal methods
480
481    def _do_save(self, req, attachment):
482        attachment.perm.require('ATTACHMENT_CREATE')
483
484        if 'cancel' in req.args:
485            req.redirect(attachment.parent.url(req.href))
486
487        upload = req.args['attachment']
488        if not hasattr(upload, 'filename') or not upload.filename:
489            raise TracError(_('No file uploaded'))
490        if hasattr(upload.file, 'fileno'):
491            size = os.fstat(upload.file.fileno())[6]
492        else:
493            upload.file.seek(0, 2) # seek to end of file
494            size = upload.file.tell()
495            upload.file.seek(0)
496        if size == 0:
497            raise TracError(_("Can't upload empty file"))
498
499        # Maximum attachment size (in bytes)
500        max_size = self.max_size
501        if max_size >= 0 and size > max_size:
502            raise TracError(_('Maximum attachment size: %(num)s bytes',
503                              num=max_size), _('Upload failed'))
504
505        # We try to normalize the filename to unicode NFC if we can.
506        # Files uploaded from OS X might be in NFD.
507        filename = unicodedata.normalize('NFC', unicode(upload.filename,
508                                                        'utf-8'))
509        filename = filename.replace('\\', '/').replace(':', '/')
510        filename = os.path.basename(filename)
511        if not filename:
512            raise TracError(_('No file uploaded'))
513        # Now the filename is known, update the attachment resource
514        attachment.id = filename
515
516        model = Attachment(self.env, parent_realm, path) 
517        model.description = req.args.get('description', '')
518        model.author = get_reporter_id(req, 'author')
519        model.ipnr = req.remote_addr
520
521        # Validate attachment
522        for manipulator in self.manipulators:
523            for field, message in manipulator.validate_attachment(req, model):
524                if field:
525                    raise InvalidAttachment(_('Attachment field %(field)s is '
526                                              'invalid: %(message)s',
527                                              field=field, message=message))
528                else:
529                    raise InvalidAttachment(_('Invalid attachment: %(message)s',
530                                              message=message))
531
532        if req.args.get('replace'):
533            try:
534                old_attachment = Attachment(self.env, attachment.parent.realm,
535                                            attachment.parent.id, filename)
536                if not (old_attachment.author and req.authname \
537                        and old_attachment.author == req.authname):
538                    attachment.perm.require('ATTACHMENT_DELETE')
539                old_attachment.delete()
540            except TracError:
541                pass # don't worry if there's nothing to replace
542            model.filename = None
543        model.insert(filename, upload.file, size)
544
545        # Redirect the user to list of attachments (must add a trailing '/')
546        req.redirect(attachment.url(req.href, '..') + '/')
547
548    def _do_delete(self, req, attachment):
549        resource.perm.require('ATTACHMENT_DELETE')
550
551        parent_href = attachment.parent.url(req.href)
552        if 'cancel' in req.args:
553            req.redirect(parent_href)
554
555        attachment.model.delete()
556        req.redirect(parent_href)
557
558    def _render_confirm_delete(self, req, attachment):
559        attachment.perm.require('ATTACHMENT_DELETE')
560        return {'mode': 'delete', 'title': _('%(attachment)s (delete)',
561                                             attachment=attachment.name),
562                'attachment': attachment.model} # FIXME
563
564    def _render_form(self, req, attachment):
565        attachment.perm.require('ATTACHMENT_CREATE')
566        return {'mode': 'new', 'author': get_reporter_id(req),
567                'attachment': attachment}
568
569    def _render_list(self, req, parent):
570        attachment = parent.assert_child('attachment')
571        data = {
572            'mode': 'list', 'attachment': None, # no specific attachment
573            'attachments': self.permitted_attachment_list(req, parent)}
574
575        add_link(req, 'up', parent.url(req.href), parent.name)
576
577        return 'attachment.html', data, None
578
579    def _render_view(self, req, attachment):
580        req.check_modified(attachment.model.date)
581
582        data = {'mode': 'view', 'title': attachment.name,
583                'attachment': attachment} 
584
585        fd = attachment.model.open()
586        try:
587            mimeview = Mimeview(self.env)
588
589            # MIME type detection
590            str_data = fd.read(1000)
591            fd.seek(0)
592           
593            mime_type = mimeview.get_mimetype(attachment.model.filename,
594                                              str_data)
595
596            # Eventually send the file directly
597            format = req.args.get('format')
598            if format in ('raw', 'txt'):
599                if not self.render_unsafe_content:
600                    # Force browser to download files instead of rendering
601                    # them, since they might contain malicious code enabling
602                    # XSS attacks
603                    req.send_header('Content-Disposition', 'attachment')
604                if format == 'txt':
605                      mime_type = 'text/plain'
606                elif not mime_type:
607                    mime_type = 'application/octet-stream'
608                if 'charset=' not in mime_type:
609                    charset = mimeview.get_charset(str_data, mime_type)
610                    mime_type = mime_type + '; charset=' + charset
611                req.send_file(attachment.model.path, mime_type)
612
613            # add ''Plain Text'' alternate link if needed
614            if (self.render_unsafe_content and 
615                mime_type and not mime_type.startswith('text/plain')):
616                plaintext_href = attachment.url(req.href, format='txt')
617                add_link(req, 'alternate', plaintext_href, _('Plain Text'),
618                         mime_type)
619
620            # add ''Original Format'' alternate link (always)
621            raw_href = attachment.url(req.href, format='raw')
622            add_link(req, 'alternate', raw_href, _('Original Format'),
623                     mime_type)
624
625            self.log.debug("Rendering preview of file %s with mime-type %s"
626                           % (attachment.model.filename, mime_type))
627
628            context = RenderingContext(req.href, attachment)
629            data['preview'] = mimeview.preview_data(
630                context, fd, os.fstat(fd.fileno()).st_size, mime_type,
631                attachment.model.filename, raw_href, annotations=['lineno'])
632            return data
633        finally:
634            fd.close()
635
636    def _format_link(self, formatter, ns, target, label):
637        link, params, fragment = formatter.split_link(target)
638        ids = link.split(':', 2)
639        attachment = None
640        if len(ids) == 3:
641            known_realms = ResourceSystem(self.env).get_known_realms()
642            # new-style attachment: TracLinks (filename:realm:id)
643            if ids[1] in known_realms:
644                parent = formatter.perm(ids[1], ids[2])
645                if parent:
646                    attachment = parent.child('attachment', ids[0])
647            else: # try old-style attachment: TracLinks (realm:id:filename)
648                if ids[0] in known_realms:
649                    parent = formatter.perm(ids[0], ids[1])
650                    if parent:
651                        attachment = parent.child('attachment', ids[2])
652        else: # local attachment: TracLinks (filename)
653            attachment = formatter.resource.child('attachment', link)
654        if attachment:
655            try:
656                check_existing = attachment.model # TODO: verify
657                format = None
658                if ns.startswith('raw'):
659                    format = 'raw'
660                return tag.a(label, class_='attachment',<