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 |
|
---|
19 | from __future__ import generators
|
---|
20 | import os
|
---|
21 | import re
|
---|
22 | import shutil
|
---|
23 | import time
|
---|
24 | import urllib
|
---|
25 |
|
---|
26 | from trac import perm, util
|
---|
27 | from trac.core import *
|
---|
28 | from trac.env import IEnvironmentSetupParticipant
|
---|
29 | from trac.mimeview import *
|
---|
30 | from trac.web import IRequestHandler
|
---|
31 | from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
|
---|
32 | from trac.wiki import IWikiSyntaxProvider
|
---|
33 |
|
---|
34 |
|
---|
35 | class 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 |
|
---|
193 | def 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 |
|
---|
209 | class 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)
|
---|