Edgewall Software

Ticket #2182: attachment.py

File attachment.py, 17.8 kB (added by takakura, 2 years ago)
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.com/license.html.
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://projects.edgewall.com/trac/.
15#
16# Author: Jonas Borgström <jonas@edgewall.com>
17#         Christopher Lenz <cmlenz@gmx.de>
18
19from __future__ import generators
20import os
21import re
22import shutil
23import time
24import urllib
25
26from trac import perm, util
27from trac.core import *
28from trac.env import IEnvironmentSetupParticipant
29from trac.mimeview import *
30from trac.web import IRequestHandler
31from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
32from trac.wiki import IWikiSyntaxProvider
33
34
35class Attachment(object):
36
37    def __init__(self, env, parent_type, parent_id, filename=None, db=None):
38        self.env = env
39        self.parent_type = parent_type
40        self.parent_id = str(parent_id)
41        if filename:
42            self._fetch(filename, db)
43        else:
44            self.filename = None
45            self.description = None
46            self.size = None
47            self.time = None
48            self.author = None
49            self.ipnr = None
50
51    def _fetch(self, filename, db=None):
52        if not db:
53            db = self.env.get_db_cnx()
54        cursor = db.cursor()
55        cursor.execute("SELECT filename,description,size,time,author,ipnr "
56                       "FROM attachment WHERE type=%s AND id=%s "
57                       "AND filename=%s ORDER BY time",
58                       (self.parent_type, str(self.parent_id), filename))
59        row = cursor.fetchone()
60        cursor.close()
61        if not row:
62            self.filename = filename
63            raise TracError('Attachment %s does not exist.' % (self.title),
64                            'Invalid Attachment')
65        self.filename = row[0]
66        self.description = row[1]
67        self.size = row[2] and int(row[2]) or 0
68        self.time = row[3] and int(row[3]) or 0
69        self.author = row[4]
70        self.ipnr = row[5]
71
72    def _get_path(self):
73        path = os.path.join(self.env.path, 'attachments', self.parent_type,
74                            urllib.quote(self.parent_id))
75        if self.filename:
76            path = os.path.join(path, urllib.quote(self.filename))
77        return os.path.normpath(path)
78    path = property(_get_path)
79
80    def href(self,*args,**dict):
81        return self.env.href.attachment(self.parent_type, self.parent_id,
82                                        self.filename, *args, **dict)
83
84    def _get_title(self):
85        return '%s%s: %s' % (self.parent_type == 'ticket' and '#' or '',
86                             self.parent_id, self.filename)
87    title = property(_get_title)
88
89    def _get_parent_href(self):
90        return self.env.href(self.parent_type, self.parent_id)
91    parent_href = property(_get_parent_href)
92
93    def delete(self, db=None):
94        assert self.filename, 'Cannot delete non-existent attachment'
95        if not db:
96            db = self.env.get_db_cnx()
97            handle_ta = True
98        else:
99            handle_ta = False
100
101        cursor = db.cursor()
102        cursor.execute("DELETE FROM attachment WHERE type=%s AND id=%s "
103                       "AND filename=%s", (self.parent_type, self.parent_id,
104                       self.filename))
105        if os.path.isfile(self.path):
106            try:
107                os.unlink(self.path)
108            except OSError:
109                self.env.log.error('Failed to delete attachment file %s',
110                                   self.path, exc_info=True)
111                if handle_ta:
112                    db.rollback()
113                raise TracError, 'Could not delete attachment'
114
115        self.env.log.info('Attachment removed: %s' % self.title)
116        if handle_ta:
117            db.commit()
118
119    def insert(self, filename, fileobj, size, t=None, db=None):
120        if not db:
121            db = self.env.get_db_cnx()
122            handle_ta = True
123        else:
124            handle_ta = False
125
126        # Maximum attachment size (in bytes)
127        max_size = int(self.env.config.get('attachment', 'max_size'))
128        if max_size >= 0 and size > max_size:
129            raise TracError('Maximum attachment size: %d bytes' % max_size,
130                            'Upload failed')
131        self.size = size
132        self.time = t or time.time()
133
134        # Make sure the path to the attachment is inside the environment
135        # attachments directory
136        attachments_dir = os.path.join(os.path.normpath(self.env.path),
137                                       'attachments')
138        commonprefix = os.path.commonprefix([attachments_dir, self.path])
139        assert commonprefix == attachments_dir
140
141        if not os.access(self.path, os.F_OK):
142            os.makedirs(self.path)
143        filename = urllib.quote(filename)
144        path, targetfile = util.create_unique_file(os.path.join(self.path,
145                                                                filename))
146        try:
147            filename = urllib.unquote(os.path.basename(path))
148
149            cursor = db.cursor()
150            cursor.execute("INSERT INTO attachment "
151                           "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
152                           (self.parent_type, self.parent_id, filename,
153                            self.size, self.time, self.description, self.author,
154                            self.ipnr))
155            shutil.copyfileobj(fileobj, targetfile)
156            self.filename = filename
157
158            self.env.log.info('New attachment: %s by %s', self.title,
159                              self.author)
160            if handle_ta:
161                db.commit()
162        finally:
163            targetfile.close()
164
165    def select(cls, env, parent_type, parent_id, db=None):
166        if not db:
167            db = env.get_db_cnx()
168        cursor = db.cursor()
169        cursor.execute("SELECT filename,description,size,time,author,ipnr "
170                       "FROM attachment WHERE type=%s AND id=%s ORDER BY time",
171                       (parent_type, str(parent_id)))
172        for filename,description,size,time,author,ipnr in cursor:
173            attachment = Attachment(env, parent_type, parent_id)
174            attachment.filename = filename
175            attachment.description = description
176            attachment.size = size
177            attachment.time = time
178            attachment.author = author
179            attachment.ipnr = ipnr
180            yield attachment
181
182    select = classmethod(select)
183
184    def open(self):
185        self.env.log.debug('Trying to open attachment at %s', self.path)
186        try:
187            fd = open(self.path, 'rb')
188        except IOError:
189            raise TracError('Attachment %s not found' % self.filename)
190        return fd
191
192
193def attachment_to_hdf(env, db, req, attachment):
194    from trac.wiki import wiki_to_oneliner
195    if not db:
196        db = env.get_db_cnx()
197    hdf = {
198        'filename': attachment.filename,
199        'description': wiki_to_oneliner(attachment.description, env, db),
200        'author': attachment.author,
201        'ipnr': attachment.ipnr,
202        'size': util.pretty_size(attachment.size),
203        'time': util.format_datetime(attachment.time),
204        'href': attachment.href()
205    }
206    return hdf
207
208
209class AttachmentModule(Component):
210
211    implements(IEnvironmentSetupParticipant, IRequestHandler,
212               INavigationContributor, IWikiSyntaxProvider)
213
214    CHUNK_SIZE = 4096
215
216    # IEnvironmentSetupParticipant methods
217
218    def environment_created(self):
219        """Create the attachments directory."""
220        if self.env.path:
221            os.mkdir(os.path.join(self.env.path, 'attachments'))
222
223    def environment_needs_upgrade(self, db):
224        return False
225
226    def upgrade_environment(self, db):
227        pass
228
229    # INavigationContributor methods
230
231    def get_active_navigation_item(self, req):
232        return req.args.get('type')
233
234    def get_navigation_items(self, req):
235        return []
236
237    # IReqestHandler methods
238
239    def match_request(self, req):
240        match = re.match(r'^/attachment/(ticket|wiki)(?:/(.*))?$', req.path_info)
241        if match:
242            req.args['type'] = match.group(1)
243            req.args['path'] = match.group(2)
244            return 1
245
246    def process_request(self, req):
247        parent_type = req.args.get('type')
248        path = req.args.get('path')
249        if not parent_type or not path:
250            raise TracError('Bad request')
251        if not parent_type in ['ticket', 'wiki']:
252            raise TracError('Unknown attachment type')
253
254        action = req.args.get('action', 'view')
255        if action == 'new':
256            attachment = Attachment(self.env, parent_type, path)
257        else:
258            segments = path.split('/')
259            parent_id = '/'.join(segments[:-1])
260            filename = segments[-1]
261            if len(segments) == 1 or not filename:
262                raise TracError('Bad request')           
263            attachment = Attachment(self.env, parent_type, parent_id, filename)
264
265        if req.method == 'POST':
266            if action == 'new':
267                self._do_save(req, attachment)
268            elif action == 'delete':
269                self._do_delete(req, attachment)
270        elif action == 'delete':
271            self._render_confirm(req, attachment)
272        elif action == 'new':
273            self._render_form(req, attachment)
274        else:
275            self._render_view(req, attachment)
276
277        add_stylesheet(req, 'common/css/code.css')
278        return 'attachment.cs', None
279
280    # IWikiSyntaxProvider methods
281   
282    def get_wiki_syntax(self):
283        return []
284
285    def get_link_resolvers(self):
286        yield ('attachment', self._format_link)
287
288    # Internal methods
289
290    def _do_save(self, req, attachment):
291        perm_map = {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY'}
292        req.perm.assert_permission(perm_map[attachment.parent_type])
293
294        if req.args.has_key('cancel'):
295            req.redirect(attachment.parent_href)
296
297        upload = req.args['attachment']
298        if not upload.filename:
299            raise TracError, 'No file uploaded'
300        if hasattr(upload.file, 'fileno'):
301            size = os.fstat(upload.file.fileno())[6]
302        else:
303            size = upload.file.len
304        if size == 0:
305            raise TracError, 'No file uploaded'
306
307        filename = upload.filename.replace('\\', '/').replace(':', '/')
308        filename = os.path.basename(filename)
309        assert filename, 'No file uploaded'
310
311        # We try to normalize the filename to utf-8 NFC if we can.
312        # Files uploaded from OS X might be in NFD.
313        import sys, unicodedata
314        if sys.version_info[0] > 2 or \
315           (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
316           filename = unicodedata.normalize('NFC',
317                                            unicode(filename,
318                                                    'utf-8')).encode('utf-8')
319
320        attachment.description = req.args.get('description', '')
321        attachment.author = req.args.get('author', '')
322        attachment.ipnr = req.remote_addr
323        if req.args.get('replace'):
324            try:
325                old_attachment = Attachment(self.env, attachment.parent_type,
326                                            attachment.parent_id, filename)
327                if not (old_attachment.author and req.authname \
328                        and old_attachment.author == req.authname):
329                    perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
330                    req.perm.assert_permission(perm_map[old_attachment.parent_type])
331                old_attachment.delete()
332            except TracError:
333                pass # don't worry if there's nothing to replace
334            attachment.filename = None
335        attachment.insert(filename, upload.file, size)
336
337        # Redirect the user to the newly created attachment
338        req.redirect(attachment.href())
339
340    def _do_delete(self, req, attachment):
341        perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
342        req.perm.assert_permission(perm_map[attachment.parent_type])
343
344        if req.args.has_key('cancel'):
345            req.redirect(attachment.href())
346
347        attachment.delete()
348
349        # Redirect the user to the attachment parent page
350        req.redirect(attachment.parent_href)
351
352    def _get_parent_link(self, attachment):
353        if attachment.parent_type == 'ticket':
354            return ('チケット #' + attachment.parent_id, attachment.parent_href)
355        elif attachment.parent_type == 'wiki':
356            return (attachment.parent_id, attachment.parent_href)
357        return (None, None)
358
359    def _render_confirm(self, req, attachment):
360        perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
361        req.perm.assert_permission(perm_map[attachment.parent_type])
362
363        req.hdf['title'] = '%s (削除)' % attachment.title
364        text, link = self._get_parent_link(attachment)
365        req.hdf['attachment'] = {
366            'filename': attachment.filename,
367            'mode': 'delete',
368            'parent': {'type': attachment.parent_type,
369                       'id': attachment.parent_id, 'name': text, 'href': link}
370        }
371
372    def _render_form(self, req, attachment):
373        perm_map = {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY'}
374        req.perm.assert_permission(perm_map[attachment.parent_type])
375
376        text, link = self._get_parent_link(attachment)
377        req.hdf['attachment'] = {
378            'mode': 'new',
379            'author': util.get_reporter_id(req),
380            'parent': {'type': attachment.parent_type,
381                       'id': attachment.parent_id, 'name': text, 'href': link}
382        }
383
384    def _render_view(self, req, attachment):
385        perm_map = {'ticket': 'TICKET_VIEW', 'wiki': 'WIKI_VIEW'}
386        req.perm.assert_permission(perm_map[attachment.parent_type])
387
388        req.check_modified(attachment.time)
389
390        # Render HTML view
391        text, link = self._get_parent_link(attachment)
392        add_link(req, 'up', link, text)
393
394        req.hdf['title'] = attachment.title
395        req.hdf['attachment'] = attachment_to_hdf(self.env, None, req, attachment)
396        req.hdf['attachment.parent'] = {
397            'type': attachment.parent_type, 'id': attachment.parent_id,
398            'name': text, 'href': link,
399        }
400
401        perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
402        if req.perm.has_permission(perm_map[attachment.parent_type]):
403            req.hdf['attachment.can_delete'] = 1
404
405        fd = attachment.open()
406        try:
407            mimeview = Mimeview(self.env)
408            max_preview_size = mimeview.max_preview_size()
409            data = fd.read(max_preview_size)
410
411            mime_type = get_mimetype(attachment.filename) or \
412                        'application/octet-stream'
413            self.log.debug("Rendering preview of file %s with mime-type %s"
414                           % (attachment.filename, mime_type))
415
416            raw_href = attachment.href(format='raw')
417            add_link(req, 'alternate', raw_href, 'Original Format', mime_type)
418            req.hdf['attachment.raw_href'] = raw_href
419
420            format = req.args.get('format')
421            render_unsafe = self.config.getbool('attachment',
422                                                'render_unsafe_content')
423            binary = not detect_unicode(data) and is_binary(data)
424
425            if format in ('raw', 'txt'): # Send raw file
426                if not render_unsafe and not binary:
427                    # Force browser to download HTML/SVG/etc pages that may
428                    # contain malicious code enabling XSS attacks
429                    req.send_header('Content-Disposition', 'attachment;' +
430                                    'filename=' + attachment.filename)
431                charset = mimeview.get_charset(data, mime_type)
432                if render_unsafe and not binary and format == 'txt':
433                    mime_type = 'text/plain'
434                req.send_file(attachment.path,
435                              mime_type + ';charset=' + charset)
436
437            if render_unsafe and not binary:
438                add_link(req, 'alternate', attachment.href(format='txt'),
439                         'Plain Text', mime_type)
440
441            hdf = mimeview.preview_to_hdf(req, mime_type, None, data,
442                                          attachment.filename, None,
443                                          annotations=['lineno'])
444            req.hdf['attachment'] = hdf
445        finally:
446            fd.close()
447
448    def _format_link(self, formatter, ns, link, label):
449        ids = link.split(':', 2)
450        params = ''
451        if len(ids) == 3:
452            parent_type, parent_id, filename = ids
453        else:
454            # FIXME: the formatter should know which object the text being
455            #        formatter belongs to
456            parent_type, parent_id = 'wiki', 'WikiStart'
457            if formatter.req:
458                path_info = formatter.req.path_info.split('/', 2)
459                if len(path_info) > 1:
460                    parent_type = path_info[1]
461                if len(path_info) > 2:
462                    parent_id = path_info[2]
463            filename = link
464        idx = filename.find('?')
465        if idx >= 0:
466            filename, params = filename[:idx], filename[idx:]
467        try:
468            attachment = Attachment(self.env, parent_type, parent_id, filename)
469            return '<a class="attachment" title="添付ファイル %s" href="%s">%s</a>' \
470                   % (util.escape(attachment.title),
471                      util.escape(attachment.href() + params),
472                      util.escape(label))
473        except TracError:
474            return '<a class="missing attachment" href="%s" rel="nofollow">%s</a>' \
475                   % (self.env.href.wiki(), label)