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