Package trac :: Package versioncontrol :: Package web_ui :: Module browser

Source Code for Module trac.versioncontrol.web_ui.browser

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2003-2020 Edgewall Software 
   4  # Copyright (C) 2003-2005 Jonas Borgström <[email protected]> 
   5  # Copyright (C) 2005-2007 Christian Boos <[email protected]> 
   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 https://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 https://trac.edgewall.org/log/. 
  15  # 
  16  # Author: Jonas Borgström <[email protected]> 
  17   
  18  import re 
  19  from datetime import datetime, timedelta 
  20  from fnmatch import fnmatchcase 
  21   
  22  from genshi.builder import tag 
  23   
  24  from trac.config import BoolOption, ListOption, Option 
  25  from trac.core import * 
  26  from trac.mimeview.api import IHTMLPreviewAnnotator, Mimeview, is_binary 
  27  from trac.perm import IPermissionRequestor, PermissionError 
  28  from trac.resource import Resource, ResourceNotFound 
  29  from trac.util import as_bool, embedded_numbers 
  30  from trac.util.datefmt import datetime_now, http_date, to_datetime, utc 
  31  from trac.util.html import Markup, escape 
  32  from trac.util.text import exception_to_unicode, shorten_line 
  33  from trac.util.translation import _, cleandoc_ 
  34  from trac.versioncontrol.api import NoSuchChangeset, RepositoryManager 
  35  from trac.versioncontrol.web_ui.util import * 
  36  from trac.web.api import IRequestHandler, RequestDone 
  37  from trac.web.chrome import (INavigationContributor, add_ctxtnav, add_link, 
  38                               add_script, add_stylesheet, prevnext_nav, 
  39                               web_context) 
  40  from trac.wiki.api import IWikiMacroProvider, IWikiSyntaxProvider, parse_args 
  41  from trac.wiki.formatter import format_to_html, format_to_oneliner 
  42   
  43   
  44  CHUNK_SIZE = 4096 
  45   
  46   
47 -class IPropertyRenderer(Interface):
48 """Render node properties in TracBrowser and TracChangeset views.""" 49
50 - def match_property(name, mode):
51 """Indicate whether this renderer can treat the given property 52 53 `mode` is the current rendering context, which can be: 54 - 'browser' rendered in the browser view 55 - 'changeset' rendered in the changeset view as a node property 56 - 'revprop' rendered in the changeset view as a revision property 57 58 Other identifiers might be used by plugins, so it's advised to simply 59 ignore unknown modes. 60 61 Returns a quality number, ranging from 0 (unsupported) to 9 62 (''perfect'' match). 63 """
64
65 - def render_property(name, mode, context, props):
66 """Render the given property. 67 68 `name` is the property name as given to `match()`, 69 `mode` is the same as for `match_property`, 70 `context` is the context for the node being render 71 (useful when the rendering depends on the node kind) and 72 `props` is the collection of the corresponding properties 73 (i.e. the `node.get_properties()`). 74 75 The rendered result can be one of the following: 76 - `None`: the property will be skipped 77 - an `unicode` value: the property will be displayed as text 78 - a `RenderedProperty` instance: the property will only be displayed 79 using the instance's `content` attribute, and the other attributes 80 will also be used in some display contexts (like `revprop`) 81 - `Markup` or other Genshi content: the property will be displayed 82 normally, using that content as a block-level markup 83 """
84 85
86 -class RenderedProperty(object):
87 - def __init__(self, name=None, name_attributes=None, 88 content=None, content_attributes=None):
89 self.name = name 90 self.name_attributes = name_attributes 91 self.content = content 92 self.content_attributes = content_attributes
93 94
95 -class DefaultPropertyRenderer(Component):
96 """Default version control property renderer.""" 97 98 implements(IPropertyRenderer) 99
100 - def match_property(self, name, mode):
101 return 1
102
103 - def render_property(self, name, mode, context, props):
104 # No special treatment besides respecting newlines in values. 105 value = props[name] 106 if value and '\n' in value: 107 value = Markup(''.join(['<br />%s' % escape(v) 108 for v in value.split('\n')])) 109 return value
110 111
112 -class WikiPropertyRenderer(Component):
113 """Wiki text property renderer.""" 114 115 implements(IPropertyRenderer) 116 117 wiki_properties = ListOption('browser', 'wiki_properties', 118 'trac:description', 119 doc="""Comma-separated list of version control properties to render 120 as wiki content in the repository browser. 121 """) 122 123 oneliner_properties = ListOption('browser', 'oneliner_properties', 124 'trac:summary', 125 doc="""Comma-separated list of version control properties to render 126 as oneliner wiki content in the repository browser. 127 """) 128
129 - def match_property(self, name, mode):
130 return 4 if name in self.wiki_properties \ 131 or name in self.oneliner_properties else 0
132
133 - def render_property(self, name, mode, context, props):
134 if name in self.wiki_properties: 135 return format_to_html(self.env, context, props[name]) 136 else: 137 return format_to_oneliner(self.env, context, props[name])
138 139
140 -class TimeRange(object):
141 142 min = datetime(1, 1, 1, 0, 0, 0, 0, utc) # tz aware version of datetime.min 143
144 - def __init__(self, base):
145 self.oldest = self.newest = base 146 self._total = None
147
148 - def seconds_between(self, dt1, dt2):
149 delta = dt1 - dt2 150 return delta.days * 24 * 3600 + delta.seconds
151
152 - def to_seconds(self, dt):
153 return self.seconds_between(dt, TimeRange.min)
154
155 - def from_seconds(self, secs):
156 return TimeRange.min + timedelta(*divmod(secs, 24* 3600))
157
158 - def relative(self, datetime):
159 if self._total is None: 160 self._total = float(self.seconds_between(self.newest, self.oldest)) 161 age = 1.0 162 if self._total: 163 age = self.seconds_between(datetime, self.oldest) / self._total 164 return age
165
166 - def insert(self, datetime):
167 self._total = None 168 self.oldest = min(self.oldest, datetime) 169 self.newest = max(self.newest, datetime)
170 171 172
173 -class BrowserModule(Component):
174 175 implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 176 IWikiSyntaxProvider, IHTMLPreviewAnnotator, 177 IWikiMacroProvider) 178 179 property_renderers = ExtensionPoint(IPropertyRenderer) 180 181 realm = RepositoryManager.source_realm 182 183 downloadable_paths = ListOption('browser', 'downloadable_paths', 184 '/trunk, /branches/*, /tags/*', 185 doc="""List of repository paths that can be downloaded. 186 187 Leave this option empty if you want to disable all downloads, otherwise 188 set it to a comma-separated list of authorized paths (those paths are 189 glob patterns, i.e. "*" can be used as a wild card). In a 190 multi-repository environment, the path must be qualified with the 191 repository name if the path does not point to the default repository 192 (e.g. /reponame/trunk). Note that a simple prefix matching is 193 performed on the paths, so aliases won't get automatically resolved. 194 """) 195 196 color_scale = BoolOption('browser', 'color_scale', True, 197 doc="""Enable colorization of the ''age'' column. 198 199 This uses the same color scale as the source code annotation: 200 blue is older, red is newer. 201 """) 202 203 NEWEST_COLOR = (255, 136, 136) 204 205 newest_color = Option('browser', 'newest_color', repr(NEWEST_COLOR), 206 doc="""(r,g,b) color triple to use for the color corresponding 207 to the newest color, for the color scale used in ''blame'' or 208 the browser ''age'' column if `color_scale` is enabled. 209 """) 210 211 OLDEST_COLOR = (136, 136, 255) 212 213 oldest_color = Option('browser', 'oldest_color', repr(OLDEST_COLOR), 214 doc="""(r,g,b) color triple to use for the color corresponding 215 to the oldest color, for the color scale used in ''blame'' or 216 the browser ''age'' column if `color_scale` is enabled. 217 """) 218 219 intermediate_point = Option('browser', 'intermediate_point', '', 220 doc="""If set to a value between 0 and 1 (exclusive), this will be the 221 point chosen to set the `intermediate_color` for interpolating 222 the color value. 223 """) 224 225 intermediate_color = Option('browser', 'intermediate_color', '', 226 doc="""(r,g,b) color triple to use for the color corresponding 227 to the intermediate color, if two linear interpolations are used 228 for the color scale (see `intermediate_point`). 229 If not set, the intermediate color between `oldest_color` and 230 `newest_color` will be used. 231 """) 232 233 render_unsafe_content = BoolOption('browser', 'render_unsafe_content', 234 'false', 235 """Whether raw files should be rendered in the browser, or only made 236 downloadable. 237 238 Pretty much any file may be interpreted as HTML by the browser, 239 which allows a malicious user to create a file containing cross-site 240 scripting attacks. 241 242 For open repositories where anyone can check-in a file, it is 243 recommended to leave this option disabled.""") 244 245 hidden_properties = ListOption('browser', 'hide_properties', 'svk:merge', 246 doc="""Comma-separated list of version control properties to hide from 247 the repository browser. 248 """) 249 250 # public methods 251
252 - def get_custom_colorizer(self):
253 """Returns a converter for values from [0.0, 1.0] to a RGB triple.""" 254 255 def interpolate(old, new, value): 256 # Provides a linearly interpolated color triple for `value` 257 # which must be a floating point value between 0.0 and 1.0 258 return tuple([int(b + (a - b) * value) for a, b in zip(new, old)])
259 260 def parse_color(rgb, default): 261 # Get three ints out of a `rgb` string or return `default` 262 try: 263 t = tuple([int(v) for v in re.split(r'(\d+)', rgb)[1::2]]) 264 return t if len(t) == 3 else default 265 except ValueError: 266 return default
267 268 newest_color = parse_color(self.newest_color, self.NEWEST_COLOR) 269 oldest_color = parse_color(self.oldest_color, self.OLDEST_COLOR) 270 try: 271 intermediate = float(self.intermediate_point) 272 except ValueError: 273 intermediate = None 274 if intermediate: 275 intermediate_color = parse_color(self.intermediate_color, None) 276 if not intermediate_color: 277 intermediate_color = tuple([(a + b) / 2 for a, b in 278 zip(newest_color, oldest_color)]) 279 def colorizer(value): 280 if value <= intermediate: 281 value = value / intermediate 282 return interpolate(oldest_color, intermediate_color, value) 283 else: 284 value = (value - intermediate) / (1.0 - intermediate) 285 return interpolate(intermediate_color, newest_color, value) 286 else: 287 def colorizer(value): 288 return interpolate(oldest_color, newest_color, value) 289 return colorizer 290 291 # INavigationContributor methods 292
293 - def get_active_navigation_item(self, req):
294 return 'browser'
295
296 - def get_navigation_items(self, req):
297 rm = RepositoryManager(self.env) 298 if any(repos.is_viewable(req.perm) for repos 299 in rm.get_real_repositories()): 300 yield ('mainnav', 'browser', 301 tag.a(_('Browse Source'), href=req.href.browser()))
302 303 # IPermissionRequestor methods 304
305 - def get_permission_actions(self):
306 return ['BROWSER_VIEW', 'FILE_VIEW']
307 308 # IRequestHandler methods 309
310 - def match_request(self, req):
311 match = re.match(r'/(export|browser|file)(/.*)?$', req.path_info) 312 if match: 313 mode, path = match.groups() 314 if mode == 'export': 315 if path and '/' in path: 316 path_elts = path.split('/', 2) 317 if len(path_elts) != 3: 318 return False 319 path = path_elts[2] 320 req.args['rev'] = path_elts[1] 321 req.args['format'] = 'raw' 322 elif mode == 'file': 323 req.redirect(req.href.browser(path, rev=req.args.get('rev'), 324 format=req.args.get('format')), 325 permanent=True) 326 req.args['path'] = path or '/' 327 return True
328
329 - def process_request(self, req):
330 presel = req.args.get('preselected') 331 if presel and (presel + '/').startswith(req.href.browser() + '/'): 332 req.redirect(presel) 333 334 path = req.args.get('path', '/') 335 rev = req.args.getfirst('rev', '') 336 if rev.lower() in ('', 'head'): 337 rev = None 338 format = req.args.get('format') 339 order = req.args.get('order', 'name').lower() 340 desc = 'desc' in req.args 341 342 rm = RepositoryManager(self.env) 343 all_repositories = rm.get_all_repositories() 344 reponame, repos, path = rm.get_repository_by_path(path) 345 346 # Repository index 347 show_index = not reponame and path == '/' 348 if show_index: 349 if repos and (as_bool(all_repositories[''].get('hidden')) 350 or not repos.is_viewable(req.perm)): 351 repos = None 352 353 if not repos and reponame: 354 raise ResourceNotFound(_("Repository '%(repo)s' not found", 355 repo=reponame)) 356 357 if reponame and reponame != repos.reponame: # Redirect alias 358 qs = req.query_string 359 req.redirect(req.href.browser(repos.reponame or None, path) 360 + ('?' + qs if qs else '')) 361 reponame = repos.reponame if repos else None 362 363 # Find node for the requested path/rev 364 context = web_context(req) 365 node = None 366 changeset = None 367 display_rev = lambda rev: rev 368 if repos: 369 try: 370 if rev: 371 rev = repos.normalize_rev(rev) 372 # If `rev` is `None`, we'll try to reuse `None` consistently, 373 # as a special shortcut to the latest revision. 374 rev_or_latest = rev or repos.youngest_rev 375 node = get_existing_node(req, repos, path, rev_or_latest) 376 except NoSuchChangeset as e: 377 raise ResourceNotFound(e, _('Invalid changeset number')) 378 if node: 379 try: 380 # use changeset instance to retrieve branches and tags 381 changeset = repos.get_changeset(node.rev) 382 except NoSuchChangeset: 383 pass 384 385 context = context.child(repos.resource.child(self.realm, path, 386 version=rev_or_latest)) 387 display_rev = repos.display_rev 388 389 # Prepare template data 390 path_links = get_path_links(req.href, reponame, path, rev, 391 order, desc) 392 393 repo_data = dir_data = file_data = None 394 if show_index: 395 repo_data = self._render_repository_index( 396 context, all_repositories, order, desc) 397 if node: 398 if not node.is_viewable(req.perm): 399 raise PermissionError('BROWSER_VIEW' if node.isdir else 400 'FILE_VIEW', node.resource, self.env) 401 if node.isdir: 402 if format in ('zip',): # extension point here... 403 self._render_zip(req, context, repos, node, rev) 404 # not reached 405 dir_data = self._render_dir(req, repos, node, rev, order, desc) 406 elif node.isfile: 407 file_data = self._render_file(req, context, repos, node, rev) 408 409 if not repos and not (repo_data and repo_data['repositories']): 410 # If no viewable repositories, check permission instead of 411 # repos.is_viewable() 412 req.perm.require('BROWSER_VIEW') 413 if show_index: 414 raise ResourceNotFound(_("No viewable repositories")) 415 else: 416 raise ResourceNotFound(_("No node %(path)s", path=path)) 417 418 quickjump_data = properties_data = None 419 if node and not req.is_xhr: 420 properties_data = self.render_properties( 421 'browser', context, node.get_properties()) 422 quickjump_data = list(repos.get_quickjump_entries(rev)) 423 424 data = { 425 'context': context, 'reponame': reponame, 'repos': repos, 426 'repoinfo': all_repositories.get(reponame or ''), 427 'path': path, 'rev': node and node.rev, 'stickyrev': rev, 428 'display_rev': display_rev, 'changeset': changeset, 429 'created_path': node and node.created_path, 430 'created_rev': node and node.created_rev, 431 'properties': properties_data, 432 'path_links': path_links, 433 'order': order, 'desc': 1 if desc else None, 434 'repo': repo_data, 'dir': dir_data, 'file': file_data, 435 'quickjump_entries': quickjump_data, 436 'wiki_format_messages': \ 437 self.config['changeset'].getbool('wiki_format_messages'), 438 'xhr': req.is_xhr, # Remove in 1.3.1 439 } 440 if req.is_xhr: # render and return the content only 441 return 'dir_entries.html', data, None 442 443 if dir_data or repo_data: 444 add_script(req, 'common/js/expand_dir.js') 445 add_script(req, 'common/js/keyboard_nav.js') 446 447 # Links for contextual navigation 448 if node: 449 if node.isfile: 450 prev_rev = repos.previous_rev(rev=node.created_rev, 451 path=node.created_path) 452 if prev_rev: 453 href = req.href.browser(reponame, 454 node.created_path, rev=prev_rev) 455 add_link(req, 'prev', href, 456 _('Revision %(num)s', num=display_rev(prev_rev))) 457 if rev is not None: 458 add_link(req, 'up', req.href.browser(reponame, 459 node.created_path)) 460 next_rev = repos.next_rev(rev=node.created_rev, 461 path=node.created_path) 462 if next_rev: 463 href = req.href.browser(reponame, node.created_path, 464 rev=next_rev) 465 add_link(req, 'next', href, 466 _('Revision %(num)s', num=display_rev(next_rev))) 467 prevnext_nav(req, _('Previous Revision'), _('Next Revision'), 468 _('Latest Revision')) 469 else: 470 if path != '/': 471 add_link(req, 'up', path_links[-2]['href'], 472 _('Parent directory')) 473 add_ctxtnav(req, tag.a(_('Last Change'), 474 href=req.href.changeset(node.created_rev, reponame, 475 node.created_path))) 476 if node.isfile: 477 annotate = data['file']['annotate'] 478 if annotate: 479 add_ctxtnav(req, _('Normal'), 480 title=_('View file without annotations'), 481 href=req.href.browser(reponame, 482 node.created_path, 483 rev=rev)) 484 if annotate != 'blame': 485 add_ctxtnav(req, _('Blame'), 486 title=_('Annotate each line with the last ' 487 'changed revision ' 488 '(this can be time consuming...)'), 489 href=req.href.browser(reponame, 490 node.created_path, 491 rev=rev, 492 annotate='blame')) 493 add_ctxtnav(req, _('Revision Log'), 494 href=req.href.log(reponame, path, rev=rev)) 495 path_url = repos.get_path_url(path, rev) 496 if path_url: 497 if path_url.startswith('//'): 498 path_url = req.scheme + ':' + path_url 499 add_ctxtnav(req, _('Repository URL'), href=path_url) 500 501 add_stylesheet(req, 'common/css/browser.css') 502 return 'browser.html', data, None
503 504 # Internal methods 505
506 - def _render_repository_index(self, context, all_repositories, order, desc):
507 # Color scale for the age column 508 timerange = custom_colorizer = None 509 if self.color_scale: 510 custom_colorizer = self.get_custom_colorizer() 511 512 rm = RepositoryManager(self.env) 513 repositories = [] 514 for reponame, repoinfo in all_repositories.iteritems(): 515 if not reponame or as_bool(repoinfo.get('hidden')): 516 continue 517 try: 518 repos = rm.get_repository(reponame) 519 except TracError as err: 520 entry = (reponame, repoinfo, None, None, 521 exception_to_unicode(err), None) 522 else: 523 if repos: 524 if not repos.is_viewable(context.perm): 525 continue 526 try: 527 youngest = repos.get_changeset(repos.youngest_rev) 528 except NoSuchChangeset: 529 youngest = None 530 if self.color_scale and youngest: 531 if not timerange: 532 timerange = TimeRange(youngest.date) 533 else: 534 timerange.insert(youngest.date) 535 raw_href = self._get_download_href(context.href, repos, 536 None, None) 537 entry = (reponame, repoinfo, repos, youngest, None, 538 raw_href) 539 else: 540 entry = (reponame, repoinfo, None, None, u"\u2013", None) 541 if entry[4] is not None: # Check permission in case of error 542 root = Resource('repository', reponame).child(self.realm, '/') 543 if 'BROWSER_VIEW' not in context.perm(root): 544 continue 545 repositories.append(entry) 546 547 # Ordering of repositories 548 if order == 'date': 549 def repo_order((reponame, repoinfo, repos, youngest, err, href)): 550 return (youngest.date if youngest else to_datetime(0), 551 embedded_numbers(reponame.lower()))
552 elif order == 'author': 553 def repo_order((reponame, repoinfo, repos, youngest, err, href)): 554 return (youngest.author.lower() if youngest else '', 555 embedded_numbers(reponame.lower())) 556 else: 557 def repo_order((reponame, repoinfo, repos, youngest, err, href)): 558 return embedded_numbers(reponame.lower()) 559 560 repositories = sorted(repositories, key=repo_order, reverse=desc) 561 562 return {'repositories' : repositories, 563 'timerange': timerange, 'colorize_age': custom_colorizer} 564
565 - def _render_dir(self, req, repos, node, rev, order, desc):
566 req.perm(node.resource).require('BROWSER_VIEW') 567 download_href = self._get_download_href 568 569 # Entries metadata 570 class entry(object): 571 _copy = 'name rev created_rev kind isdir path content_length' \ 572 .split() 573 __slots__ = _copy + ['raw_href'] 574 575 def __init__(self, node): 576 for f in entry._copy: 577 setattr(self, f, getattr(node, f)) 578 self.raw_href = download_href(req.href, repos, node, rev)
579 580 entries = [entry(n) for n in node.get_entries() 581 if n.is_viewable(req.perm)] 582 changes = get_changes(repos, [i.created_rev for i in entries], 583 self.log) 584 585 if rev: 586 newest = repos.get_changeset(rev).date 587 else: 588 newest = datetime_now(req.tz) 589 590 # Color scale for the age column 591 timerange = custom_colorizer = None 592 if self.color_scale: 593 timerange = TimeRange(newest) 594 max_s = req.args.get('range_max_secs') 595 min_s = req.args.get('range_min_secs') 596 parent_range = [timerange.from_seconds(long(s)) 597 for s in [max_s, min_s] if s] 598 this_range = [c.date for c in changes.values() if c] 599 for dt in this_range + parent_range: 600 timerange.insert(dt) 601 custom_colorizer = self.get_custom_colorizer() 602 603 # Ordering of entries 604 if order == 'date': 605 def file_order(a): 606 return (changes[a.created_rev].date, 607 embedded_numbers(a.name.lower())) 608 elif order == 'size': 609 def file_order(a): 610 return (a.content_length, 611 embedded_numbers(a.name.lower())) 612 elif order == 'author': 613 def file_order(a): 614 return (changes[a.created_rev].author.lower(), 615 embedded_numbers(a.name.lower())) 616 else: 617 def file_order(a): 618 return embedded_numbers(a.name.lower()) 619 620 dir_order = 1 if desc else -1 621 622 def browse_order(a): 623 return dir_order if a.isdir else 0, file_order(a) 624 entries = sorted(entries, key=browse_order, reverse=desc) 625 626 # ''Zip Archive'' alternate link 627 zip_href = self._get_download_href(req.href, repos, node, rev) 628 if zip_href: 629 add_link(req, 'alternate', zip_href, _('Zip Archive'), 630 'application/zip', 'zip') 631 632 return {'entries': entries, 'changes': changes, 633 'timerange': timerange, 'colorize_age': custom_colorizer, 634 'range_max_secs': (timerange and 635 timerange.to_seconds(timerange.newest)), 636 'range_min_secs': (timerange and 637 timerange.to_seconds(timerange.oldest)), 638 } 639
640 - def _iter_nodes(self, node):
641 stack = [node] 642 while stack: 643 node = stack.pop() 644 yield node 645 if node.isdir: 646 stack.extend(sorted(node.get_entries(), 647 key=lambda x: x.name, 648 reverse=True))
649
650 - def _render_zip(self, req, context, repos, root_node, rev=None):
651 if not self.is_path_downloadable(repos, root_node.path): 652 raise TracError(_("Path not available for download")) 653 req.perm(context.resource).require('FILE_VIEW') 654 root_path = root_node.path.rstrip('/') 655 if root_path: 656 archive_name = root_node.name 657 else: 658 archive_name = repos.reponame or 'repository' 659 filename = '%s-%s.zip' % (archive_name, root_node.rev) 660 render_zip(req, filename, repos, root_node, self._iter_nodes)
661
662 - def _render_file(self, req, context, repos, node, rev=None):
663 req.perm(node.resource).require('FILE_VIEW') 664 665 mimeview = Mimeview(self.env) 666 667 # MIME type detection 668 with content_closing(node.get_processed_content()) as content: 669 chunk = content.read(CHUNK_SIZE) 670 mime_type = node.content_type 671 if not mime_type or mime_type == 'application/octet-stream': 672 mime_type = mimeview.get_mimetype(node.name, chunk) or \ 673 mime_type or 'text/plain' 674 675 # Eventually send the file directly 676 format = req.args.get('format') 677 if format in ('raw', 'txt'): 678 req.send_response(200) 679 req.send_header('Content-Type', 680 'text/plain' if format == 'txt' else mime_type) 681 req.send_header('Last-Modified', http_date(node.last_modified)) 682 if rev is None: 683 req.send_header('Pragma', 'no-cache') 684 req.send_header('Cache-Control', 'no-cache') 685 req.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') 686 if not self.render_unsafe_content: 687 # Force browser to download files instead of rendering 688 # them, since they might contain malicious code enabling 689 # XSS attacks 690 req.send_header('Content-Disposition', 'attachment') 691 req.end_headers() 692 # Note: don't pass an iterable instance to RequestDone, instead 693 # call req.write() with each chunk here to avoid SEGVs (#11805) 694 while chunk: 695 req.write(chunk) 696 chunk = content.read(CHUNK_SIZE) 697 raise RequestDone 698 699 # The changeset corresponding to the last change on `node` 700 # is more interesting than the `rev` changeset. 701 changeset = repos.get_changeset(node.created_rev) 702 703 # add ''Plain Text'' alternate link if needed 704 if not is_binary(chunk) and mime_type != 'text/plain': 705 plain_href = req.href.browser(repos.reponame or None, 706 node.path, rev=rev, 707 format='txt') 708 add_link(req, 'alternate', plain_href, _('Plain Text'), 709 'text/plain') 710 711 # add ''Original Format'' alternate link (always) 712 raw_href = req.href.export(rev or repos.youngest_rev, 713 repos.reponame or None, node.path) 714 add_link(req, 'alternate', raw_href, _('Original Format'), 715 mime_type) 716 717 self.log.debug("Rendering preview of node %s@%s with " 718 "mime-type %s", node.name, rev, mime_type) 719 720 add_stylesheet(req, 'common/css/code.css') 721 722 annotations = ['lineno'] 723 annotate = req.args.get('annotate') 724 if annotate: 725 annotations.insert(0, annotate) 726 with content_closing(node.get_processed_content()) as content: 727 preview_data = mimeview.preview_data(context, content, 728 node.get_content_length(), 729 mime_type, node.created_path, 730 raw_href, 731 annotations=annotations, 732 force_source=bool(annotate)) 733 return { 734 'changeset': changeset, 735 'size': node.content_length, 736 'preview': preview_data, 737 'annotate': annotate, 738 }
739
740 - def _get_download_href(self, href, repos, node, rev):
741 """Return the URL for downloading a file, or a directory as a ZIP.""" 742 if node is not None and node.isfile: 743 return href.export(rev or 'HEAD', repos.reponame or None, 744 node.path) 745 path = '' if node is None else node.path.strip('/') 746 if self.is_path_downloadable(repos, path): 747 return href.browser(repos.reponame or None, path, 748 rev=rev or repos.youngest_rev, format='zip')
749 750 # public methods 751
752 - def is_path_downloadable(self, repos, path):
753 if repos.reponame: 754 path = repos.reponame + '/' + path 755 return any(fnmatchcase(path, dp.strip('/')) 756 for dp in self.downloadable_paths)
757
758 - def render_properties(self, mode, context, props):
759 """Prepare rendering of a collection of properties.""" 760 return filter(None, [self.render_property(name, mode, context, props) 761 for name in sorted(props)])
762
763 - def render_property(self, name, mode, context, props):
764 """Renders a node property to HTML.""" 765 if name in self.hidden_properties: 766 return 767 candidates = [] 768 for renderer in self.property_renderers: 769 quality = renderer.match_property(name, mode) 770 if quality > 0: 771 candidates.append((quality, renderer)) 772 candidates.sort(reverse=True) 773 for (quality, renderer) in candidates: 774 try: 775 rendered = renderer.render_property(name, mode, context, props) 776 if not rendered: 777 return rendered 778 if isinstance(rendered, RenderedProperty): 779 value = rendered.content 780 else: 781 value = rendered 782 rendered = None 783 prop = {'name': name, 'value': value, 'rendered': rendered} 784 return prop 785 except Exception as e: 786 self.log.warning('Rendering failed for property %s with ' 787 'renderer %s: %s', name, 788 renderer.__class__.__name__, 789 exception_to_unicode(e, traceback=True))
790 791 # IWikiSyntaxProvider methods 792
793 - def get_wiki_syntax(self):
794 return []
795 814 829 849 850 PATH_LINK_RE = re.compile(r"([^@#:]*)" # path 851 r"[@:]([^#:]+)?" # rev 852 r"(?::(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?" # marks 853 ) 854 868 869 # IHTMLPreviewAnnotator methods 870
871 - def get_annotation_type(self):
872 return 'blame', _('Rev'), _('Revision in which the line changed')
873
874 - def get_annotation_data(self, context):
875 """Cache the annotation data corresponding to each revision.""" 876 return BlameAnnotator(self.env, context)
877
878 - def annotate_row(self, context, row, lineno, line, blame_annotator):
879 blame_annotator.annotate(row, lineno)
880 881 # IWikiMacroProvider methods 882
883 - def get_macros(self):
884 yield "RepositoryIndex"
885
886 - def get_macro_description(self, name):
887 description = cleandoc_(""" 888 Display the list of available repositories. 889 890 Can be given the following named arguments: 891 892 ''format'':: 893 Select the rendering format: 894 - ''compact'' produces a comma-separated list of repository prefix 895 names (default) 896 - ''list'' produces a description list of repository prefix names 897 - ''table'' produces a table view, similar to the one visible in 898 the ''Browse View'' page 899 ''glob'':: 900 Do a glob-style filtering on the repository names (defaults to '*') 901 ''order'':: 902 Order repositories by the given column (one of "name", "date" or 903 "author") 904 ''desc'':: 905 When set to 1, order by descending order 906 907 (''since 0.12'') 908 """) 909 return 'messages', description
910
911 - def expand_macro(self, formatter, name, content):
912 args, kwargs = parse_args(content) 913 format = kwargs.get('format', 'compact') 914 glob = kwargs.get('glob', '*') 915 order = kwargs.get('order') 916 desc = as_bool(kwargs.get('desc', 0)) 917 918 rm = RepositoryManager(self.env) 919 all_repos = dict(rdata for rdata in rm.get_all_repositories().items() 920 if fnmatchcase(rdata[0], glob)) 921 922 if format == 'table': 923 repo = self._render_repository_index(formatter.context, all_repos, 924 order, desc) 925 926 add_stylesheet(formatter.req, 'common/css/browser.css') 927 wiki_format_messages = self.config['changeset'] \ 928 .getbool('wiki_format_messages') 929 data = {'repo': repo, 'order': order, 'desc': 1 if desc else None, 930 'reponame': None, 'path': '/', 'stickyrev': None, 931 'wiki_format_messages': wiki_format_messages} 932 from trac.web.chrome import Chrome 933 return Chrome(self.env).render_template( 934 formatter.req, 'repository_index.html', data, None, 935 fragment=True) 936 937 def get_repository(reponame): 938 try: 939 return rm.get_repository(reponame) 940 except TracError: 941 return
942 943 all_repos = [(reponame, get_repository(reponame)) 944 for reponame in all_repos] 945 all_repos = sorted(((reponame, repos) for reponame, repos in all_repos 946 if repos 947 and not as_bool(repos.params.get('hidden')) 948 and repos.is_viewable(formatter.perm)), 949 reverse=desc) 950 951 def repolink(reponame, repos): 952 label = reponame or _('(default)') 953 return Markup(tag.a(label, 954 title=_('View repository %(repo)s', repo=label), 955 href=formatter.href.browser(repos.reponame or None))) 956 957 if format == 'list': 958 return tag.dl([ 959 tag(tag.dt(repolink(reponame, repos)), 960 tag.dd(repos.params.get('description'))) 961 for reponame, repos in all_repos]) 962 else: # compact 963 return Markup(', ').join([repolink(reponame, repos) 964 for reponame, repos in all_repos]) 965 966 967
968 -class BlameAnnotator(object):
969
970 - def __init__(self, env, context):
971 self.env = env 972 self.context = context 973 rm = RepositoryManager(self.env) 974 self.repos = rm.get_repository(context.resource.parent.id) 975 self.path = context.resource.id 976 self.rev = context.resource.version 977 # maintain state 978 self.prev_chgset = None 979 self.chgset_data = {} 980 add_script(context.req, 'common/js/blame.js') 981 add_stylesheet(context.req, 'common/css/changeset.css') 982 add_stylesheet(context.req, 'common/css/diff.css') 983 self.reset()
984
985 - def reset(self):
986 rev = self.rev 987 node = self.repos.get_node(self.path, rev) 988 # FIXME: get_annotations() should be in the Resource API 989 # -- get revision numbers for each line 990 self.annotations = node.get_annotations() 991 # -- from the annotations, retrieve changesets and 992 # determine the span of dates covered, for the color code. 993 # Note: changesets[i].rev can differ from annotations[i] 994 # (long form vs. compact, short rev form for the latter). 995 self.changesets = [] 996 chgset = self.repos.get_changeset(rev) 997 chgsets = {rev: chgset} 998 self.timerange = TimeRange(chgset.date) 999 for idx in range(len(self.annotations)): 1000 rev = self.annotations[idx] 1001 chgset = chgsets.get(rev) 1002 if not chgset: 1003 chgset = self.repos.get_changeset(rev) 1004 chgsets[rev] = chgset 1005 self.timerange.insert(chgset.date) 1006 # get list of changeset parallel to annotations 1007 self.changesets.append(chgset) 1008 # -- retrieve the original path of the source, for each rev 1009 # (support for copy/renames) 1010 self.paths = {} 1011 for path, rev, chg in node.get_history(): 1012 self.paths[rev] = path 1013 # -- get custom colorize function 1014 browser = BrowserModule(self.env) 1015 self.colorize_age = browser.get_custom_colorizer()
1016
1017 - def annotate(self, row, lineno):
1018 if lineno > len(self.annotations): 1019 row.append(tag.th()) 1020 return 1021 rev = self.annotations[lineno-1] 1022 chgset = self.changesets[lineno-1] 1023 path = self.paths.get(rev) 1024 # Note: path will be None if copy/rename is not supported 1025 # by get_history 1026 1027 # -- compute anchor and style once per revision 1028 if rev not in self.chgset_data: 1029 chgset_href = \ 1030 self.context.href.changeset(rev, self.repos.reponame or None, 1031 path) 1032 short_author = chgset.author.split(' ', 1)[0] 1033 title = shorten_line('%s: %s' % (short_author, chgset.message)) 1034 anchor = tag.a('[%s]' % self.repos.short_rev(rev), # shortname 1035 title=title, href=chgset_href) 1036 color = self.colorize_age(self.timerange.relative(chgset.date)) 1037 style = 'background-color: rgb(%d, %d, %d);' % color 1038 self.chgset_data[rev] = (anchor, style) 1039 else: 1040 anchor, style = self.chgset_data[rev] 1041 1042 if self.prev_chgset != chgset: 1043 self.prev_style = style 1044 # optimize away the path if there's no copy/rename info 1045 if not path or path == self.path: 1046 path = '' 1047 # -- produce blame column, eventually with an anchor 1048 style = self.prev_style 1049 if lineno < len(self.changesets) and self.changesets[lineno] == chgset: 1050 style += ' border-bottom: none;' 1051 blame_col = tag.th(style=style, class_='blame r%s' % rev) 1052 if self.prev_chgset != chgset: 1053 blame_col.append(anchor) 1054 self.prev_chgset = chgset 1055 row.append(blame_col)
1056