def __get__(self, instance, owner): # FIXME: Better handling of recursive imports from multiproduct.env import ProductEnvironment if instance is None: return self components = OrderedExtensionsOption.__get__(self, instance, owner) env = getattr(instance, 'env', None) return [MultiproductPermissionPolicy(env)] + components \ if isinstance(env, ProductEnvironment) \ else components
class PullRequestWorkflowProxy(Component): """Provides a special workflow for pull requests and forwards others. Don't forget to replace the `TicketActionController` in the workflow option in the `[ticket]` section in TracIni. Your original controller for tickets other than pull requests can be added as general_workflow option. If there was only the default workflow option before, the lines will look like this: {{{ [ticket] workflow = PullRequestWorkflowProxy general_workflow = ConfigurableTicketWorkflow }}} """ implements(ITicketActionController) action_controllers = OrderedExtensionsOption( 'ticket', 'general_workflow', ITicketActionController, default='ConfigurableTicketWorkflow', include_missing=False, doc="""Ordered list of workflow controllers to use for general tickets. That is when a ticket is not a pull request. """) ### ITicketActionController methods def get_ticket_actions(self, req, ticket): if ticket['type'] != 'pull request': items = (controller.get_ticket_actions(req, ticket) for controller in self.action_controllers) return chain.from_iterable(items) rm = RepositoryManager(self.env) repo = rm.get_repository_by_id(ticket['pr_dstrepo'], True) srcrepo = rm.get_repository_by_id(ticket['pr_srcrepo'], True) current_status = ticket._old.get('status', ticket['status']) or 'new' current_owner = ticket._old.get('owner', ticket['owner']) actions = [] actions.append((4, 'leave')) if current_status != 'closed' and req.authname in repo.maintainers(): actions.append((3, 'accept')) actions.append((2, 'reject')) if not current_owner or repo.maintainers() - set([current_owner]): actions.append((1, 'reassign')) actions.append((0, 'review')) if current_status == 'closed': if srcrepo and repo: actions.append((0, 'reopen')) return actions def get_all_status(self): items = (controller.get_all_status() for controller in self.action_controllers) return list(chain.from_iterable(items)) + ['under review'] def render_ticket_action_control(self, req, ticket, action): if ticket['type'] != 'pull request': items = [ controller.render_ticket_action_control(req, ticket, action) for controller in self.action_controllers ] return chain.from_iterable(self._filter_resolutions(req, items)) rm = RepositoryManager(self.env) repo = rm.get_repository_by_id(ticket['pr_dstrepo'], True) current_status = ticket._old.get('status', ticket['status']) or 'new' current_owner = ticket._old.get('owner', ticket['owner']) control = [] hints = [] if action == 'leave': control.append(_('as %(status)s ', status=current_status)) if current_owner: hints.append( _("The owner will remain %(current_owner)s", current_owner=current_owner)) else: hints.append( _("The ticket will remain with no owner", owner=current_owner)) if action == 'accept': if repo.has_node('', ticket['pr_srcrev']): hints.append(_("The request will be accepted")) hints.append(_("Next status will be '%(name)s'", name='closed')) else: hints.append( _("The changes must be merged into '%(repo)s' " "first", repo=repo.reponame)) if action == 'reject': if not repo.has_node('', ticket['pr_srcrev']): hints.append(_("The request will be rejected")) hints.append(_("Next status will be '%(name)s'", name='closed')) else: hints.append( _("The changes are already present in '%(repo)s'", repo=repo.reponame)) if action == 'reassign': maintainers = (set([repo.owner]) | repo.maintainers()) maintainers -= set([current_owner]) selected_owner = req.args.get('action_reassign_reassign_owner', req.authname) control.append( tag.select([ tag.option( x, value=x, selected=(x == selected_owner or None)) for x in maintainers ], id='action_reassign_reassign_owner', name='action_reassign_reassign_owner')) hints.append( _( "The owner will be changed from %(old)s to the " "selected user. Next status will be 'assigned'", old=current_owner)) if action == 'review': if current_owner != req.authname: hints.append( _( "The owner will be changes from " "%(current_owner)s to %(authname)s", current_owner=current_owner, authname=req.authname)) hints.append( _("Next status will be '%(name)s'", name='under review')) if action == 'reopen': hints.append(_("The resolution will be deleted")) hints.append(_("Next status will be '%(name)s'", name='reopened')) return (action, tag(control), '. '.join(hints) + '.') def get_ticket_changes(self, req, ticket, action): changes = {} if ticket['type'] != 'pull request': for controller in self.action_controllers: changes.update( controller.get_ticket_changes(req, ticket, action)) return changes updated = {} if action == 'accept': updated['resolution'] = 'accepted' updated['status'] = 'closed' if action == 'reject': updated['resolution'] = 'rejected' updated['status'] = 'closed' if action == 'reassign': updated['owner'] = req.args.get('action_reassign_reassign_owner') updated['status'] = 'assigned' if action == 'review': updated['owner'] = req.authname updated['status'] = 'under review' if action == 'reopen': updated['resolution'] = '' updated['status'] = 'reopened' return updated def apply_action_side_effects(self, req, ticket, action): if ticket['type'] != 'pull request': items = (controller.get_action_side_effects(req, ticket, action) for controller in self.action_controllers) return chain.from_iterable(items) ### Private methods def _filter_resolutions(self, req, items): for item in items: if item[0] != 'resolve': yield item return resolutions = [ val.name for val in Resolution.select(self.env) if int(val.value) > 0 ] ts = TicketSystem(self.env) selected_option = req.args.get('action_resolve_resolve_resolution', ts.default_resolution) control = tag.select([ tag.option(x, value=x, selected=(x == selected_option or None)) for x in resolutions ], id='action_resolve_resolve_resolution', name='action_resolve_resolve_resolution') yield ('resolve', tag_('as %(resolution)s', resolution=control), item[2])
class RequestDispatcher(Component): """Web request dispatcher. This component dispatches incoming requests to registered handlers. Besides, it also takes care of user authentication and request pre- and post-processing. """ required = True authenticators = ExtensionPoint(IAuthenticator) handlers = ExtensionPoint(IRequestHandler) filters = OrderedExtensionsOption( 'trac', 'request_filters', IRequestFilter, doc="""Ordered list of filters to apply to all requests (''since 0.10'').""") default_handler = ExtensionOption( 'trac', 'default_handler', IRequestHandler, 'WikiModule', """Name of the component that handles requests to the base URL. Options include `TimelineModule`, `RoadmapModule`, `BrowserModule`, `QueryModule`, `ReportModule`, `TicketModule` and `WikiModule`. The default is `WikiModule`. (''since 0.9'')""") default_timezone = Option('trac', 'default_timezone', '', """The default timezone to use""") default_language = Option( 'trac', 'default_language', '', """The preferred language to use if no user preference has been set. (''since 0.12.1'') """) default_date_format = Option( 'trac', 'default_date_format', '', """The date format. Valid options are 'iso8601' for selecting ISO 8601 format, or leave it empty which means the default date format will be inferred from the browser's default language. (''since 1.0'') """) use_xsendfile = BoolOption( 'trac', 'use_xsendfile', 'false', """When true, send a `X-Sendfile` header and no content when sending files from the filesystem, so that the web server handles the content. This requires a web server that knows how to handle such a header, like Apache with `mod_xsendfile` or lighttpd. (''since 1.0'') """) xsendfile_header = Option( 'trac', 'xsendfile_header', 'X-Sendfile', """The header to use if `use_xsendfile` is enabled. If Nginx is used, set `X-Accel-Redirect`. (''since 1.0.6'')""") # Public API def authenticate(self, req): for authenticator in self.authenticators: try: authname = authenticator.authenticate(req) except TracError, e: self.log.error("Can't authenticate using %s: %s", authenticator.__class__.__name__, exception_to_unicode(e, traceback=True)) add_warning( req, _("Authentication error. " "Please contact your administrator.")) break # don't fallback to other authenticators if authname: return authname return 'anonymous'
class RequestDispatcher(Component): """Web request dispatcher. This component dispatches incoming requests to registered handlers. It also takes care of user authentication and request pre- and post-processing. """ required = True implements(ITemplateProvider) authenticators = ExtensionPoint(IAuthenticator) handlers = ExtensionPoint(IRequestHandler) filters = OrderedExtensionsOption('trac', 'request_filters', IRequestFilter, doc="""Ordered list of filters to apply to all requests.""") default_handler = ExtensionOption('trac', 'default_handler', IRequestHandler, 'WikiModule', """Name of the component that handles requests to the base URL. Options include `TimelineModule`, `RoadmapModule`, `BrowserModule`, `QueryModule`, `ReportModule`, `TicketModule` and `WikiModule`. The [/prefs/userinterface session preference] for default handler take precedence, when set. """) default_timezone = Option('trac', 'default_timezone', '', """The default timezone to use""") default_language = Option('trac', 'default_language', '', """The preferred language to use if no user preference has been set. """) default_date_format = ChoiceOption('trac', 'default_date_format', ('', 'iso8601'), """The date format. Valid options are 'iso8601' for selecting ISO 8601 format, or leave it empty which means the default date format will be inferred from the browser's default language. (''since 1.0'') """) use_xsendfile = BoolOption('trac', 'use_xsendfile', 'false', """When true, send a `X-Sendfile` header and no content when sending files from the filesystem, so that the web server handles the content. This requires a web server that knows how to handle such a header, like Apache with `mod_xsendfile` or lighttpd. (''since 1.0'') """) xsendfile_header = Option('trac', 'xsendfile_header', 'X-Sendfile', """The header to use if `use_xsendfile` is enabled. If Nginx is used, set `X-Accel-Redirect`. (''since 1.0.6'')""") configurable_headers = ConfigSection('http-headers', """ Headers to be added to the HTTP request. (''since 1.2.3'') The header name must conform to RFC7230 and the following reserved names are not allowed: content-type, content-length, location, etag, pragma, cache-control, expires. """) # Public API def authenticate(self, req): for authenticator in self.authenticators: try: authname = authenticator.authenticate(req) except TracError as e: self.log.error("Can't authenticate using %s: %s", authenticator.__class__.__name__, exception_to_unicode(e, traceback=True)) add_warning(req, _("Authentication error. " "Please contact your administrator.")) break # don't fallback to other authenticators if authname: return authname return 'anonymous' def dispatch(self, req): """Find a registered handler that matches the request and let it process it. In addition, this method initializes the data dictionary passed to the the template and adds the web site chrome. """ self.log.debug('Dispatching %r', req) chrome = Chrome(self.env) try: # Select the component that should handle the request chosen_handler = None for handler in self._request_handlers.values(): if handler.match_request(req): chosen_handler = handler break if not chosen_handler and req.path_info in ('', '/'): chosen_handler = self._get_valid_default_handler(req) # pre-process any incoming request, whether a handler # was found or not self.log.debug("Chosen handler is %s", chosen_handler) chosen_handler = self._pre_process_request(req, chosen_handler) if not chosen_handler: if req.path_info.endswith('/'): # Strip trailing / and redirect target = unicode_quote(req.path_info.rstrip('/')) if req.query_string: target += '?' + req.query_string req.redirect(req.href + target, permanent=True) raise HTTPNotFound('No handler matched request to %s', req.path_info) req.callbacks['chrome'] = partial(chrome.prepare_request, handler=chosen_handler) # Protect against CSRF attacks: we validate the form token # for all POST requests with a content-type corresponding # to form submissions if req.method == 'POST': ctype = req.get_header('Content-Type') if ctype: ctype, options = cgi.parse_header(ctype) if ctype in ('application/x-www-form-urlencoded', 'multipart/form-data') and \ req.args.get('__FORM_TOKEN') != req.form_token: if self.env.secure_cookies and req.scheme == 'http': msg = _('Secure cookies are enabled, you must ' 'use https to submit forms.') else: msg = _('Do you have cookies enabled?') raise HTTPBadRequest(_('Missing or invalid form token.' ' %(msg)s', msg=msg)) # Process the request and render the template resp = chosen_handler.process_request(req) if resp: template, data, metadata = \ self._post_process_request(req, *resp) if 'hdfdump' in req.args: req.perm.require('TRAC_ADMIN') # debugging helper - no need to render first out = io.BytesIO() pprint({'template': template, 'metadata': metadata, 'data': data}, out) req.send(out.getvalue(), 'text/plain') self.log.debug("Rendering response with template %s", template) metadata.setdefault('iterable', chrome.use_chunked_encoding) content_type = metadata.get('content_type') output = chrome.render_template(req, template, data, metadata) req.send(output, content_type or 'text/html') else: self.log.debug("Empty or no response from handler. " "Entering post_process_request.") self._post_process_request(req) except RequestDone: raise except Exception as e: # post-process the request in case of errors err = sys.exc_info() try: self._post_process_request(req) except RequestDone: raise except TracError as e2: self.log.warning("Exception caught while post-processing" " request: %s", exception_to_unicode(e2)) except Exception as e2: if not (type(e) is type(e2) and e.args == e2.args): self.log.error("Exception caught while post-processing" " request: %s", exception_to_unicode(e2, traceback=True)) if isinstance(e, PermissionError): raise HTTPForbidden(e) if isinstance(e, ResourceNotFound): raise HTTPNotFound(e) if isinstance(e, NotImplementedError): tb = traceback.extract_tb(err[2])[-1] self.log.warning("%s caught from %s:%d in %s: %s", e.__class__.__name__, tb[0], tb[1], tb[2], to_unicode(e) or "(no message)") raise HTTPInternalServerError(TracNotImplementedError(e)) if isinstance(e, TracError): raise HTTPInternalServerError(e) raise err[0], err[1], err[2] # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac.web', 'templates')] # Internal methods def set_default_callbacks(self, req): """Setup request callbacks for lazily-evaluated properties. """ req.callbacks.update({ 'authname': self.authenticate, 'chrome': Chrome(self.env).prepare_request, 'form_token': self._get_form_token, 'lc_time': self._get_lc_time, 'locale': self._get_locale, 'perm': self._get_perm, 'session': self._get_session, 'tz': self._get_timezone, 'use_xsendfile': self._get_use_xsendfile, 'xsendfile_header': self._get_xsendfile_header, 'configurable_headers': self._get_configurable_headers, }) @lazy def _request_handlers(self): return {handler.__class__.__name__: handler for handler in self.handlers} def _get_valid_default_handler(self, req): # Use default_handler from the Session if it is a valid value. name = req.session.get('default_handler') handler = self._request_handlers.get(name) if handler and not is_valid_default_handler(handler): handler = None if not handler: # Use default_handler from project configuration. handler = self.default_handler if not is_valid_default_handler(handler): raise ConfigurationError( tag_("%(handler)s is not a valid default handler. Please " "update %(option)s through the %(page)s page or by " "directly editing trac.ini.", handler=tag.code(handler.__class__.__name__), option=tag.code("[trac] default_handler"), page=tag.a(_("Basic Settings"), href=req.href.admin('general/basics')))) return handler def _get_perm(self, req): if isinstance(req.session, FakeSession): return FakePerm() else: return PermissionCache(self.env, req.authname) def _get_session(self, req): try: return Session(self.env, req) except TracError as e: msg = "can't retrieve session: %s" if isinstance(e, TracValueError): self.log.warning(msg, e) else: self.log.error(msg, exception_to_unicode(e)) return FakeSession() def _get_locale(self, req): if has_babel: preferred = req.session.get('language') default = self.default_language negotiated = get_negotiated_locale([preferred, default] + req.languages) self.log.debug("Negotiated locale: %s -> %s", preferred, negotiated) return negotiated def _get_lc_time(self, req): lc_time = req.session.get('lc_time') if not lc_time or lc_time == 'locale' and not has_babel: lc_time = self.default_date_format if lc_time == 'iso8601': return 'iso8601' return req.locale def _get_timezone(self, req): try: return timezone(req.session.get('tz', self.default_timezone or 'missing')) except Exception: return localtz def _get_form_token(self, req): """Used to protect against CSRF. The 'form_token' is strong shared secret stored in a user cookie. By requiring that every POST form to contain this value we're able to protect against CSRF attacks. Since this value is only known by the user and not by an attacker. If the the user does not have a `trac_form_token` cookie a new one is generated. """ if 'trac_form_token' in req.incookie: return req.incookie['trac_form_token'].value else: req.outcookie['trac_form_token'] = form_token = hex_entropy(24) req.outcookie['trac_form_token']['path'] = req.base_path or '/' if self.env.secure_cookies: req.outcookie['trac_form_token']['secure'] = True req.outcookie['trac_form_token']['httponly'] = True return form_token def _get_use_xsendfile(self, req): return self.use_xsendfile @lazy def _xsendfile_header(self): header = self.xsendfile_header.strip() if Request.is_valid_header(header): return to_utf8(header) else: if not self._warn_xsendfile_header: self._warn_xsendfile_header = True self.log.warning("[trac] xsendfile_header is invalid: '%s'", header) return None def _get_xsendfile_header(self, req): return self._xsendfile_header @lazy def _configurable_headers(self): headers = [] invalids = [] for name, val in self.configurable_headers.options(): if Request.is_valid_header(name, val): headers.append((name, val)) else: invalids.append((name, val)) if invalids: self.log.warning('[http-headers] invalid headers are ignored: %s', ', '.join('%r: %r' % i for i in invalids)) return tuple(headers) def _get_configurable_headers(self, req): return iter(self._configurable_headers) def _pre_process_request(self, req, chosen_handler): for filter_ in self.filters: chosen_handler = filter_.pre_process_request(req, chosen_handler) return chosen_handler def _post_process_request(self, req, *args): metadata = {} resp = args if len(args) == 3: metadata = args[2] elif len(args) == 2: resp += (metadata,) elif len(args) == 0: resp = (None,) * 3 for f in reversed(self.filters): resp = f.post_process_request(req, *resp) if len(resp) == 2: resp += (metadata,) return resp
class TicketSystem(Component): implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager, ITicketManipulator) change_listeners = ExtensionPoint(ITicketChangeListener) milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener) realm = 'ticket' ticket_custom_section = ConfigSection( 'ticket-custom', """In this section, you can define additional fields for tickets. See TracTicketsCustomFields for more details.""") action_controllers = OrderedExtensionsOption( 'ticket', 'workflow', ITicketActionController, default='ConfigurableTicketWorkflow', include_missing=False, doc="""Ordered list of workflow controllers to use for ticket actions. """) restrict_owner = BoolOption( 'ticket', 'restrict_owner', 'false', """Make the owner field of tickets use a drop-down menu. Be sure to understand the performance implications before activating this option. See [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List]. Please note that e-mail addresses are '''not''' obfuscated in the resulting drop-down menu, so this option should not be used if e-mail addresses must remain protected. """) default_version = Option('ticket', 'default_version', '', """Default version for newly created tickets.""") default_type = Option('ticket', 'default_type', 'defect', """Default type for newly created tickets.""") default_priority = Option( 'ticket', 'default_priority', 'major', """Default priority for newly created tickets.""") default_milestone = Option( 'ticket', 'default_milestone', '', """Default milestone for newly created tickets.""") default_component = Option( 'ticket', 'default_component', '', """Default component for newly created tickets.""") default_severity = Option( 'ticket', 'default_severity', '', """Default severity for newly created tickets.""") default_summary = Option( 'ticket', 'default_summary', '', """Default summary (title) for newly created tickets.""") default_description = Option( 'ticket', 'default_description', '', """Default description for newly created tickets.""") default_keywords = Option( 'ticket', 'default_keywords', '', """Default keywords for newly created tickets.""") default_owner = Option( 'ticket', 'default_owner', '< default >', """Default owner for newly created tickets. The component owner is used when set to the value `< default >`. """) default_cc = Option('ticket', 'default_cc', '', """Default cc: list for newly created tickets.""") default_resolution = Option( 'ticket', 'default_resolution', 'fixed', """Default resolution for resolving (closing) tickets.""") allowed_empty_fields = ListOption( 'ticket', 'allowed_empty_fields', 'milestone, version', doc="""Comma-separated list of `select` fields that can have an empty value. (//since 1.1.2//)""") max_comment_size = IntOption( 'ticket', 'max_comment_size', 262144, """Maximum allowed comment size in characters.""") max_description_size = IntOption( 'ticket', 'max_description_size', 262144, """Maximum allowed description size in characters.""") max_summary_size = IntOption( 'ticket', 'max_summary_size', 262144, """Maximum allowed summary size in characters. (//since 1.0.2//)""") def __init__(self): self.log.debug('action controllers for ticket workflow: %r', [c.__class__.__name__ for c in self.action_controllers]) # Public API def get_available_actions(self, req, ticket): """Returns a sorted list of available actions""" # The list should not have duplicates. actions = {} for controller in self.action_controllers: weighted_actions = controller.get_ticket_actions(req, ticket) or [] for weight, action in weighted_actions: if action in actions: actions[action] = max(actions[action], weight) else: actions[action] = weight all_weighted_actions = [(weight, action) for action, weight in actions.items()] return [x[1] for x in sorted(all_weighted_actions, reverse=True)] def get_all_status(self): """Returns a sorted list of all the states all of the action controllers know about.""" valid_states = set() for controller in self.action_controllers: valid_states.update(controller.get_all_status() or []) return sorted(valid_states) def get_ticket_field_labels(self): """Produce a (name,label) mapping from `get_ticket_fields`.""" labels = {f['name']: f['label'] for f in self.get_ticket_fields()} labels['attachment'] = _("Attachment") return labels def get_ticket_fields(self): """Returns list of fields available for tickets. Each field is a dict with at least the 'name', 'label' (localized) and 'type' keys. It may in addition contain the 'custom' key, the 'optional' and the 'options' keys. When present 'custom' and 'optional' are always `True`. """ fields = copy.deepcopy(self.fields) label = 'label' # workaround gettext extraction bug for f in fields: f[label] = gettext(f[label]) return fields def reset_ticket_fields(self): """Invalidate ticket field cache.""" del self.fields @cached def fields(self): """Return the list of fields available for tickets.""" from trac.ticket import model fields = TicketFieldList() # Basic text fields fields.append({ 'name': 'summary', 'type': 'text', 'label': N_('Summary') }) fields.append({ 'name': 'reporter', 'type': 'text', 'label': N_('Reporter') }) # Owner field, by default text but can be changed dynamically # into a drop-down depending on configuration (restrict_owner=true) fields.append({'name': 'owner', 'type': 'text', 'label': N_('Owner')}) # Description fields.append({ 'name': 'description', 'type': 'textarea', 'format': 'wiki', 'label': N_('Description') }) # Default select and radio fields selects = [('type', N_('Type'), model.Type), ('status', N_('Status'), model.Status), ('priority', N_('Priority'), model.Priority), ('milestone', N_('Milestone'), model.Milestone), ('component', N_('Component'), model.Component), ('version', N_('Version'), model.Version), ('severity', N_('Severity'), model.Severity), ('resolution', N_('Resolution'), model.Resolution)] for name, label, cls in selects: options = [val.name for val in cls.select(self.env)] if not options: # Fields without possible values are treated as if they didn't # exist continue field = { 'name': name, 'type': 'select', 'label': label, 'value': getattr(self, 'default_' + name, ''), 'options': options } if name in ('status', 'resolution'): field['type'] = 'radio' field['optional'] = True elif name in self.allowed_empty_fields: field['optional'] = True fields.append(field) # Advanced text fields fields.append({ 'name': 'keywords', 'type': 'text', 'format': 'list', 'label': N_('Keywords') }) fields.append({ 'name': 'cc', 'type': 'text', 'format': 'list', 'label': N_('Cc') }) # Date/time fields fields.append({ 'name': 'time', 'type': 'time', 'format': 'relative', 'label': N_('Created') }) fields.append({ 'name': 'changetime', 'type': 'time', 'format': 'relative', 'label': N_('Modified') }) for field in self.custom_fields: if field['name'] in [f['name'] for f in fields]: self.log.warning('Duplicate field name "%s" (ignoring)', field['name']) continue fields.append(field) return fields reserved_field_names = [ 'report', 'order', 'desc', 'group', 'groupdesc', 'col', 'row', 'format', 'max', 'page', 'verbose', 'comment', 'or', 'id', 'time', 'changetime', 'owner', 'reporter', 'cc', 'summary', 'description', 'keywords' ] def get_custom_fields(self): return copy.deepcopy(self.custom_fields) @cached def custom_fields(self): """Return the list of custom ticket fields available for tickets.""" fields = TicketFieldList() config = self.ticket_custom_section for name in [ option for option, value in config.options() if '.' not in option ]: field = { 'name': name, 'custom': True, 'type': config.get(name), 'order': config.getint(name + '.order', 0), 'label': config.get(name + '.label') or name.replace("_", " ").strip().capitalize(), 'value': config.get(name + '.value', '') } if field['type'] == 'select' or field['type'] == 'radio': field['options'] = config.getlist(name + '.options', sep='|') if '' in field['options'] or \ field['name'] in self.allowed_empty_fields: field['optional'] = True if '' in field['options']: field['options'].remove('') elif field['type'] == 'checkbox': field['value'] = '1' if as_bool(field['value']) else '0' elif field['type'] == 'text': field['format'] = config.get(name + '.format', 'plain') field['max_size'] = config.getint(name + '.max_size', 0) elif field['type'] == 'textarea': field['format'] = config.get(name + '.format', 'plain') field['max_size'] = config.getint(name + '.max_size', 0) field['height'] = config.getint(name + '.rows') elif field['type'] == 'time': field['format'] = config.get(name + '.format', 'datetime') if field['name'] in self.reserved_field_names: self.log.warning( 'Field name "%s" is a reserved name ' '(ignoring)', field['name']) continue if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): self.log.warning( 'Invalid name for custom field: "%s" ' '(ignoring)', field['name']) continue fields.append(field) fields.sort(key=lambda f: (f['order'], f['name'])) return fields def get_field_synonyms(self): """Return a mapping from field name synonyms to field names. The synonyms are supposed to be more intuitive for custom queries.""" # i18n TODO - translated keys return {'created': 'time', 'modified': 'changetime'} def eventually_restrict_owner(self, field, ticket=None): """Restrict given owner field to be a list of users having the TICKET_MODIFY permission (for the given ticket) """ if self.restrict_owner: field['type'] = 'select' field['options'] = self.get_allowed_owners(ticket) field['optional'] = True def get_allowed_owners(self, ticket=None): """Returns a list of permitted ticket owners (those possessing the TICKET_MODIFY permission). Returns `None` if the option `[ticket]` `restrict_owner` is `False`. If `ticket` is not `None`, fine-grained permission checks are used to determine the allowed owners for the specified resource. :since: 1.0.3 """ if self.restrict_owner: allowed_owners = [] for user in PermissionSystem(self.env) \ .get_users_with_permission('TICKET_MODIFY'): if not ticket or \ 'TICKET_MODIFY' in PermissionCache(self.env, user, ticket.resource): allowed_owners.append(user) allowed_owners.sort() return allowed_owners # ITicketManipulator methods def prepare_ticket(self, req, ticket, fields, actions): pass def validate_ticket(self, req, ticket): # Validate select fields for known values. for field in ticket.fields: if 'options' not in field: continue name = field['name'] if name == 'status': continue if name in ticket and name in ticket._old: value = ticket[name] if value: if value not in field['options']: yield name, _('"%(value)s" is not a valid value', value=value) elif not field.get('optional', False): yield name, _("field cannot be empty") # Validate description length. if len(ticket['description'] or '') > self.max_description_size: yield 'description', _( "Must be less than or equal to %(num)s " "characters", num=self.max_description_size) # Validate summary length. if not ticket['summary']: yield 'summary', _("Tickets must contain a summary.") elif len(ticket['summary'] or '') > self.max_summary_size: yield 'summary', _( "Must be less than or equal to %(num)s " "characters", num=self.max_summary_size) # Validate custom field length. for field in ticket.custom_fields: field_attrs = ticket.fields.by_name(field) max_size = field_attrs.get('max_size', 0) if 0 < max_size < len(ticket[field] or ''): label = field_attrs.get('label') yield label or field, _( "Must be less than or equal to " "%(num)s characters", num=max_size) # Validate time field content. for field in ticket.time_fields: value = ticket[field] if field in ticket.custom_fields and \ field in ticket._old and \ not isinstance(value, datetime): field_attrs = ticket.fields.by_name(field) format = field_attrs.get('format') try: ticket[field] = user_time(req, parse_date, value, hint=format) \ if value else None except TracError as e: # Degrade TracError to warning. ticket[field] = value label = field_attrs.get('label') yield label or field, to_unicode(e) def validate_comment(self, req, comment): # Validate comment length if len(comment or '') > self.max_comment_size: yield _("Must be less than or equal to %(num)s characters", num=self.max_comment_size) # IPermissionRequestor methods def get_permission_actions(self): return [ 'TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP', 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', 'TICKET_EDIT_COMMENT', ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']), ('TICKET_ADMIN', [ 'TICKET_CREATE', 'TICKET_MODIFY', 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', 'TICKET_EDIT_COMMENT' ]) ] # IWikiSyntaxProvider methods def get_link_resolvers(self): return [('bug', self._format_link), ('issue', self._format_link), ('ticket', self._format_link), ('comment', self._format_comment_link)] def get_wiki_syntax(self): yield ( # matches #... but not &#... (HTML entity) r"!?(?<!&)#" # optional intertrac shorthand #T... + digits r"(?P<it_ticket>%s)%s" % (WikiParser.INTERTRAC_SCHEME, Ranges.RE_STR), lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z)) def _format_link(self, formatter, ns, target, label, fullmatch=None): intertrac = formatter.shorthand_intertrac_helper( ns, target, label, fullmatch) if intertrac: return intertrac try: link, params, fragment = formatter.split_link(target) r = Ranges(link) if len(r) == 1: num = r.a ticket = formatter.resource(self.realm, num) from trac.ticket.model import Ticket if Ticket.id_is_valid(num) and \ 'TICKET_VIEW' in formatter.perm(ticket): # TODO: attempt to retrieve ticket view directly, # something like: t = Ticket.view(num) for type, summary, status, resolution in \ self.env.db_query(""" SELECT type, summary, status, resolution FROM ticket WHERE id=%s """, (str(num),)): description = self.format_summary( summary, status, resolution, type) title = '#%s: %s' % (num, description) href = formatter.href.ticket(num) + params + fragment return tag.a(label, title=title, href=href, class_='%s ticket' % status) else: ranges = str(r) if params: params = '&' + params[1:] label_wrap = label.replace(',', u',\u200b') ranges_wrap = ranges.replace(',', u', ') return tag.a(label_wrap, title=_("Tickets %(ranges)s", ranges=ranges_wrap), href=formatter.href.query(id=ranges) + params) except ValueError: pass return tag.a(label, class_='missing ticket') def _format_comment_link(self, formatter, ns, target, label): resource = None if ':' in target: elts = target.split(':') if len(elts) == 3: cnum, realm, id = elts if cnum != 'description' and cnum and not cnum[0].isdigit(): realm, id, cnum = elts # support old comment: style id = as_int(id, None) if realm in ('bug', 'issue'): realm = 'ticket' resource = formatter.resource(realm, id) else: resource = formatter.resource cnum = target if resource and resource.id and resource.realm == self.realm and \ cnum and (cnum.isdigit() or cnum == 'description'): href = title = class_ = None if self.resource_exists(resource): from trac.ticket.model import Ticket ticket = Ticket(self.env, resource.id) if cnum != 'description' and not ticket.get_change(cnum): title = _("ticket comment does not exist") class_ = 'missing ticket' elif 'TICKET_VIEW' in formatter.perm(resource): href = formatter.href.ticket(resource.id) + \ "#comment:%s" % cnum if resource.id != formatter.resource.id: summary = self.format_summary(ticket['summary'], ticket['status'], ticket['resolution'], ticket['type']) if cnum == 'description': title = _("Description for #%(id)s: %(summary)s", id=resource.id, summary=summary) else: title = _( "Comment %(cnum)s for #%(id)s: " "%(summary)s", cnum=cnum, id=resource.id, summary=summary) class_ = ticket['status'] + ' ticket' else: title = _("Description") if cnum == 'description' \ else _("Comment %(cnum)s", cnum=cnum) class_ = 'ticket' else: title = _("no permission to view ticket") class_ = 'forbidden ticket' else: title = _("ticket does not exist") class_ = 'missing ticket' return tag.a(label, class_=class_, href=href, title=title) return label # IResourceManager methods def get_resource_realms(self): yield self.realm def get_resource_description(self, resource, format=None, context=None, **kwargs): if format == 'compact': return '#%s' % resource.id elif format == 'summary': from trac.ticket.model import Ticket ticket = Ticket(self.env, resource.id) args = [ ticket[f] for f in ('summary', 'status', 'resolution', 'type') ] return self.format_summary(*args) return _("Ticket #%(shortname)s", shortname=resource.id) def format_summary(self, summary, status=None, resolution=None, type=None): summary = shorten_line(summary) if type: summary = type + ': ' + summary if status: if status == 'closed' and resolution: status += ': ' + resolution return "%s (%s)" % (summary, status) else: return summary def resource_exists(self, resource): """ >>> from trac.test import EnvironmentStub >>> from trac.resource import Resource, resource_exists >>> env = EnvironmentStub() >>> resource_exists(env, Resource('ticket', 123456)) False >>> from trac.ticket.model import Ticket >>> t = Ticket(env) >>> int(t.insert()) 1 >>> resource_exists(env, t.resource) True """ try: id_ = int(resource.id) except (TypeError, ValueError): return False if self.env.db_query("SELECT id FROM ticket WHERE id=%s", (id_, )): if resource.version is None: return True revcount = self.env.db_query( """ SELECT count(DISTINCT time) FROM ticket_change WHERE ticket=%s """, (id_, )) return revcount[0][0] >= resource.version else: return False
class EmailDistributor(Component): """Distributes notification events as emails.""" implements(INotificationDistributor) formatters = ExtensionPoint(INotificationFormatter) decorators = ExtensionPoint(IEmailDecorator) resolvers = OrderedExtensionsOption( 'notification', 'email_address_resolvers', IEmailAddressResolver, 'SessionEmailResolver', include_missing=False, doc="""Comma separated list of email resolver components in the order they will be called. If an email address is resolved, the remaining resolvers will not be called. """) default_format = Option( 'notification', 'default_format.email', 'text/plain', doc="Default format to distribute email notifications.") def __init__(self): self._charset = create_charset( self.config.get('notification', 'mime_encoding')) # INotificationDistributor methods def transports(self): yield 'email' def distribute(self, transport, recipients, event): if transport != 'email': return if not self.config.getbool('notification', 'smtp_enabled'): self.log.debug("%s skipped because smtp_enabled set to false", self.__class__.__name__) return formats = {} for f in self.formatters: for style, realm in f.get_supported_styles(transport): if realm == event.realm: formats[style] = f if not formats: self.log.error("%s No formats found for %s %s", self.__class__.__name__, transport, event.realm) return self.log.debug( "%s has found the following formats capable of " "handling '%s' of '%s': %s", self.__class__.__name__, transport, event.realm, ', '.join(formats)) matcher = RecipientMatcher(self.env) notify_sys = NotificationSystem(self.env) always_cc = set(notify_sys.smtp_always_cc_list) addresses = {} for sid, auth, addr, fmt in recipients: if fmt not in formats: self.log.debug("%s format %s not available for %s %s", self.__class__.__name__, fmt, transport, event.realm) continue if sid and not addr: for resolver in self.resolvers: addr = resolver.get_address_for_session(sid, auth) or None if addr: self.log.debug( "%s found the address '%s' for '%s [%s]' via %s", self.__class__.__name__, addr, sid, auth, resolver.__class__.__name__) break if sid and auth and not addr: addr = sid if notify_sys.smtp_default_domain and \ not notify_sys.use_short_addr and \ addr and matcher.nodomaddr_re.match(addr): addr = '%s@%s' % (addr, notify_sys.smtp_default_domain) if not addr: self.log.debug( "%s was unable to find an address for " "'%s [%s]'", self.__class__.__name__, sid, auth) elif matcher.is_email(addr) or \ notify_sys.use_short_addr and \ matcher.nodomaddr_re.match(addr): addresses.setdefault(fmt, set()).add(addr) if sid and auth and sid in always_cc: always_cc.discard(sid) always_cc.add(addr) elif notify_sys.use_public_cc: always_cc.add(addr) else: self.log.debug( "%s was unable to use an address '%s' for '%s " "[%s]'", self.__class__.__name__, addr, sid, auth) outputs = {} failed = [] for fmt, formatter in formats.iteritems(): if fmt not in addresses and fmt != 'text/plain': continue try: outputs[fmt] = formatter.format(transport, fmt, event) except Exception as e: self.log.warning( '%s caught exception while ' 'formatting %s to %s for %s: %s%s', self.__class__.__name__, event.realm, fmt, transport, formatter.__class__, exception_to_unicode(e, traceback=True)) failed.append(fmt) # Fallback to text/plain when formatter is broken if failed and 'text/plain' in outputs: for fmt in failed: addresses.setdefault('text/plain', set()) \ .update(addresses.pop(fmt, ())) for fmt, addrs in addresses.iteritems(): self.log.debug("%s is sending event as '%s' to: %s", self.__class__.__name__, fmt, ', '.join(addrs)) message = self._create_message(fmt, outputs) if message: addrs = set(addrs) cc_addrs = sorted(addrs & always_cc) bcc_addrs = sorted(addrs - always_cc) self._do_send(transport, event, message, cc_addrs, bcc_addrs) else: self.log.warning("%s cannot send event '%s' as '%s': %s", self.__class__.__name__, event.realm, fmt, ', '.join(addrs)) def _create_message(self, format, outputs): if format not in outputs: return None message = create_mime_multipart('related') maintype, subtype = format.split('/') preferred = create_mime_text(outputs[format], subtype, self._charset) if format != 'text/plain' and 'text/plain' in outputs: alternative = create_mime_multipart('alternative') alternative.attach( create_mime_text(outputs['text/plain'], 'plain', self._charset)) alternative.attach(preferred) preferred = alternative message.attach(preferred) return message def _do_send(self, transport, event, message, cc_addrs, bcc_addrs): notify_sys = NotificationSystem(self.env) smtp_from = notify_sys.smtp_from smtp_from_name = notify_sys.smtp_from_name or self.env.project_name smtp_replyto = notify_sys.smtp_replyto if not notify_sys.use_short_addr and notify_sys.smtp_default_domain: if smtp_from and '@' not in smtp_from: smtp_from = '%s@%s' % (smtp_from, notify_sys.smtp_default_domain) if smtp_replyto and '@' not in smtp_replyto: smtp_replyto = '%s@%s' % (smtp_replyto, notify_sys.smtp_default_domain) headers = {} headers['X-Mailer'] = 'Trac %s, by Edgewall Software'\ % self.env.trac_version headers['X-Trac-Version'] = self.env.trac_version headers['X-Trac-Project'] = self.env.project_name headers['X-URL'] = self.env.project_url headers['X-Trac-Realm'] = event.realm headers['Precedence'] = 'bulk' headers['Auto-Submitted'] = 'auto-generated' if isinstance(event.target, (list, tuple)): targetid = ','.join(map(get_target_id, event.target)) else: targetid = get_target_id(event.target) rootid = create_message_id(self.env, targetid, smtp_from, None, more=event.realm) if event.category == 'created': headers['Message-ID'] = rootid else: headers['Message-ID'] = create_message_id(self.env, targetid, smtp_from, event.time, more=event.realm) headers['In-Reply-To'] = rootid headers['References'] = rootid headers['Date'] = formatdate() headers['From'] = (smtp_from_name, smtp_from) \ if smtp_from_name else smtp_from headers['To'] = 'undisclosed-recipients: ;' if cc_addrs: headers['Cc'] = ', '.join(cc_addrs) if bcc_addrs: headers['Bcc'] = ', '.join(bcc_addrs) headers['Reply-To'] = smtp_replyto for k, v in headers.iteritems(): set_header(message, k, v, self._charset) for decorator in self.decorators: decorator.decorate_message(event, message, self._charset) from_name, from_addr = parseaddr(str(message['From'])) to_addrs = set() for name in ('To', 'Cc', 'Bcc'): values = map(str, message.get_all(name, ())) to_addrs.update(addr for name, addr in getaddresses(values) if addr) del message['Bcc'] notify_sys.send_email(from_addr, list(to_addrs), message.as_string())
class RequestDispatcher(Component): """Web request dispatcher. This component dispatches incoming requests to registered handlers. Besides, it also takes care of user authentication and request pre- and post-processing. """ required = True authenticators = ExtensionPoint(IAuthenticator) handlers = ExtensionPoint(IRequestHandler) filters = OrderedExtensionsOption( 'trac', 'request_filters', IRequestFilter, doc="""Ordered list of filters to apply to all requests (''since 0.10'').""") default_handler = ExtensionOption( 'trac', 'default_handler', IRequestHandler, 'WikiModule', """Name of the component that handles requests to the base URL. Options include `TimelineModule`, `RoadmapModule`, `BrowserModule`, `QueryModule`, `ReportModule`, `TicketModule` and `WikiModule`. The default is `WikiModule`. (''since 0.9'')""") default_timezone = Option('trac', 'default_timezone', '', """The default timezone to use""") default_language = Option( 'trac', 'default_language', '', """The preferred language to use if no user preference has been set. (''since 0.12.1'') """) default_date_format = Option( 'trac', 'default_date_format', '', """The date format. Valid options are 'iso8601' for selecting ISO 8601 format, or leave it empty which means the default date format will be inferred from the browser's default language. (''since 1.0'') """) use_xsendfile = BoolOption( 'trac', 'use_xsendfile', 'false', """When true, send a `X-Sendfile` header and no content when sending files from the filesystem, so that the web server handles the content. This requires a web server that knows how to handle such a header, like Apache with `mod_xsendfile` or lighttpd. (''since 1.0'') """) # Public API def authenticate(self, req): for authenticator in self.authenticators: authname = authenticator.authenticate(req) if authname: return authname else: return 'anonymous' def dispatch(self, req): """Find a registered handler that matches the request and let it process it. In addition, this method initializes the data dictionary passed to the the template and adds the web site chrome. """ self.log.debug('Dispatching %r', req) chrome = Chrome(self.env) # Setup request callbacks for lazily-evaluated properties req.callbacks.update({ 'authname': self.authenticate, 'chrome': chrome.prepare_request, 'perm': self._get_perm, 'session': self._get_session, 'locale': self._get_locale, 'lc_time': self._get_lc_time, 'tz': self._get_timezone, 'form_token': self._get_form_token, 'use_xsendfile': self._get_use_xsendfile, }) try: try: # Select the component that should handle the request chosen_handler = None try: for handler in self.handlers: if handler.match_request(req): chosen_handler = handler break if not chosen_handler: if not req.path_info or req.path_info == '/': chosen_handler = self.default_handler # pre-process any incoming request, whether a handler # was found or not chosen_handler = self._pre_process_request( req, chosen_handler) except TracError, e: raise HTTPInternalError(e) if not chosen_handler: if req.path_info.endswith('/'): # Strip trailing / and redirect target = req.path_info.rstrip('/').encode('utf-8') if req.query_string: target += '?' + req.query_string req.redirect(req.href + target, permanent=True) raise HTTPNotFound('No handler matched request to %s', req.path_info) req.callbacks['chrome'] = partial(chrome.prepare_request, handler=chosen_handler) # Protect against CSRF attacks: we validate the form token # for all POST requests with a content-type corresponding # to form submissions if req.method == 'POST': ctype = req.get_header('Content-Type') if ctype: ctype, options = cgi.parse_header(ctype) if ctype in ('application/x-www-form-urlencoded', 'multipart/form-data') and \ req.args.get('__FORM_TOKEN') != req.form_token: if self.env.secure_cookies and req.scheme == 'http': msg = _('Secure cookies are enabled, you must ' 'use https to submit forms.') else: msg = _('Do you have cookies enabled?') raise HTTPBadRequest( _('Missing or invalid form token.' ' %(msg)s', msg=msg)) # Process the request and render the template resp = chosen_handler.process_request(req) if resp: if len(resp) == 2: # old Clearsilver template and HDF data self.log.error( "Clearsilver template are no longer " "supported (%s)", resp[0]) raise TracError( _("Clearsilver templates are no longer supported, " "please contact your Trac administrator.")) # Genshi template, data, content_type = \ self._post_process_request(req, *resp) if 'hdfdump' in req.args: req.perm.require('TRAC_ADMIN') # debugging helper - no need to render first out = StringIO() pprint(data, out) req.send(out.getvalue(), 'text/plain') output = chrome.render_template(req, template, data, content_type) req.send(output, content_type or 'text/html') else: self._post_process_request(req) except RequestDone: raise except: # post-process the request in case of errors err = sys.exc_info() try: self._post_process_request(req) except RequestDone: raise except Exception, e: self.log.error( "Exception caught while post-processing" " request: %s", exception_to_unicode(e, traceback=True)) raise err[0], err[1], err[2]
class EmailDistributor(Component): implements(IAnnouncementDistributor, IAnnouncementPreferenceProvider) formatters = ExtensionPoint(IAnnouncementFormatter) resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\ 'SessionEmailResolver, DefaultDomainEmailResolver', doc="""Comma seperated list of email resolver components in the order they will be called. If an email address is resolved, the remaining resolvers will no be called.""") smtp_enabled = BoolOption('announcer', 'smtp_enabled', 'false', """Enable SMTP (email) notification.""") smtp_debuglevel = IntOption('announcer', 'smtp_debuglevel', 0, """Debug level to pass to smtp python lib""") smtp_server = Option( 'announcer', 'smtp_server', 'localhost', """SMTP server hostname to use for email notifications.""") smtp_port = IntOption( 'announcer', 'smtp_port', 25, """SMTP server port to use for email notification.""") smtp_user = Option('announcer', 'smtp_user', '', """Username for SMTP server. (''since 0.9'').""") smtp_password = Option('announcer', 'smtp_password', '', """Password for SMTP server. (''since 0.9'').""") smtp_from = Option('announcer', 'smtp_from', 'trac@localhost', """Sender address to use in notification emails.""") smtp_ssl = BoolOption('announcer', 'smtp_ssl', 'false', doc="""Use ssl for smtp connection.""") smtp_from_name = Option('announcer', 'smtp_from_name', '', """Sender name to use in notification emails.""") smtp_replyto = Option( 'announcer', 'smtp_replyto', 'trac@localhost', """Reply-To address to use in notification emails.""") smtp_always_cc = Option( 'announcer', 'smtp_always_cc', '', """Email address(es) to always send notifications to, addresses can be see by all recipients (Cc:).""") smtp_always_bcc = Option( 'announcer', 'smtp_always_bcc', '', """Email address(es) to always send notifications to, addresses do not appear publicly (Bcc:). (''since 0.10'').""") ignore_domains = Option( 'announcer', 'ignore_domains', '', """Comma-separated list of domains that should not be considered part of email addresses (for usernames with Kerberos domains)""") admit_domains = Option( 'announcer', 'admit_domains', '', """Comma-separated list of domains that should be considered as valid for email addresses (such as localdomain)""") mime_encoding = Option( 'announcer', 'mime_encoding', 'base64', """Specifies the MIME encoding scheme for emails. Valid options are 'base64' for Base64 encoding, 'qp' for Quoted-Printable, and 'none' for no encoding. Note that the no encoding means that non-ASCII characters in text are going to cause problems with notifications (''since 0.10'').""") use_public_cc = BoolOption( 'announcer', 'use_public_cc', 'false', """Recipients can see email addresses of other CC'ed recipients. If this option is disabled (the default), recipients are put on BCC (''since 0.10'').""") use_short_addr = BoolOption( 'announcer', 'use_short_addr', 'false', """Permit email address without a host/domain (i.e. username only) The SMTP server should accept those addresses, and either append a FQDN or use local delivery (''since 0.10'').""") use_tls = BoolOption( 'announcer', 'use_tls', 'false', """Use SSL/TLS to send notifications (''since 0.10'').""") set_message_id = BoolOption( 'announcer', 'set_message_id', 'true', """Disable if you would prefer to let the email server handle message-id generation. """) smtp_subject_prefix = Option( 'announcer', 'smtp_subject_prefix', '__default__', """Text to prepend to subject line of notification emails. If the setting is not defined, then the [$project_name] prefix. If no prefix is desired, then specifying an empty option will disable it.(''since 0.10.1'').""") smtp_to = Option('announcer', 'smtp_to', None, 'Default To: field') use_threaded_delivery = BoolOption( 'announcer', 'use_threaded_delivery', 'false', """If true, the actual delivery of the message will occur in a separate thread. Enabling this will improve responsiveness for requests that end up with an announcement being sent over email. It requires building Python with threading support enabled-- which is usually the case. To test, start Python and type 'import threading' to see if it raises an error.""") default_email_format = Option( 'announcer', 'default_email_format', 'text/plain', doc="""The default mime type of the email notifications. This can be overriden on a per user basis through the announcer preferences panel.""") def __init__(self): self.delivery_queue = None self._init_pref_encoding() def get_delivery_queue(self): if not self.delivery_queue: self.delivery_queue = Queue.Queue() thread = DeliveryThread(self.delivery_queue, self._transmit) thread.start() return self.delivery_queue # IAnnouncementDistributor def get_distribution_transport(self): return "email" def formats(self, transport, realm): "Find valid formats for transport and realm" formats = {} for f in self.formatters: if f.get_format_transport() == transport: if realm in f.get_format_realms(transport): styles = f.get_format_styles(transport, realm) for style in styles: formats[style] = f self.log.debug( "EmailDistributor has found the following formats capable " "of handling '%s' of '%s': %s" % (transport, realm, ', '.join(formats.keys()))) if not formats: self.log.error("EmailDistributor is unable to continue " \ "without supporting formatters.") return formats def distribute(self, transport, recipients, event): if not self.smtp_enabled or \ transport != self.get_distribution_transport(): self.log.debug("EmailDistributer smtp_enabled set to false") return fmtdict = self.formats(transport, event.realm) if not fmtdict: self.log.error("EmailDistributer No formats found for %s %s" % (transport, event.realm)) return msgdict = {} for name, authed, addr in recipients: fmt = name and \ self._get_preferred_format(event.realm, name, authed) or \ self._get_default_format() if fmt not in fmtdict: self.log.debug(("EmailDistributer format %s not available" + "for %s %s, looking for an alternative") % (fmt, transport, event.realm)) # If the fmt is not available for this realm, then try to find # an alternative oldfmt = fmt fmt = None for f in fmtdict.values(): fmt = f.get_format_alternative(transport, event.realm, oldfmt) if fmt: break if not fmt: self.log.error( "EmailDistributer was unable to find a formatter " + "for format %s" % k) continue rslvr = None if name and not addr: # figure out what the addr should be if it's not defined for rslvr in self.resolvers: addr = rslvr.get_address_for_name(name, authed) if addr: break if addr: self.log.debug("EmailDistributor found the " \ "address '%s' for '%s (%s)' via: %s"%( addr, name, authed and \ 'authenticated' or 'not authenticated', rslvr.__class__.__name__)) # ok, we found an addr, add the message msgdict.setdefault(fmt, set()).add((name, authed, addr)) else: self.log.debug("EmailDistributor was unable to find an " \ "address for: %s (%s)"%(name, authed and \ 'authenticated' or 'not authenticated')) for k, v in msgdict.items(): if not v or not fmtdict.get(k): continue self.log.debug("EmailDistributor is sending event as '%s' to: %s" % (fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k]) def _get_default_format(self): return self.default_email_format def _get_preferred_format(self, realm, sid, authenticated): if authenticated is None: authenticated = 0 db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ SELECT value FROM session_attribute WHERE sid=%s AND authenticated=%s AND name=%s """, (sid, int(authenticated), 'announcer_email_format_%s' % realm)) result = cursor.fetchone() if result: chosen = result[0] self.log.debug("EmailDistributor determined the preferred format" \ " for '%s (%s)' is: %s"%(sid, authenticated and \ 'authenticated' or 'not authenticated', chosen)) return chosen else: return self._get_default_format() def _init_pref_encoding(self): from email.Charset import Charset, QP, BASE64 self._charset = Charset() self._charset.input_charset = 'utf-8' pref = self.mime_encoding.lower() if pref == 'base64': self._charset.header_encoding = BASE64 self._charset.body_encoding = BASE64 self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref in ['qp', 'quoted-printable']: self._charset.header_encoding = QP self._charset.body_encoding = QP self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref == 'none': self._charset.header_encoding = None self._charset.body_encoding = None self._charset.input_codec = None self._charset.output_charset = 'ascii' else: raise TracError(_('Invalid email encoding setting: %s' % pref)) def _message_id(self, event, event_id, modtime): """Generate a predictable, but sufficiently unique message ID.""" s = '%s.%s.%d' % (self.env.project_url, event_id, modtime) dig = md5(s).hexdigest() host = self.smtp_from[self.smtp_from.find('@') + 1:] msgid = '<%03d.%s@%s>' % (len(s), dig, host) return msgid def _event_id(self, event): """FIXME: badly needs improvement Hacked bullshit. """ if hasattr(event.target, 'id'): return "%08d" % event.target.id elif hasattr(event.target, 'name'): return event.target.name else: return str(event.target) def _do_send(self, transport, event, format, recipients, formatter): output = formatter.format(transport, event.realm, format, event) subject = formatter.format_subject(transport, event.realm, format, event) alternate_format = formatter.get_format_alternative( transport, event.realm, format) if alternate_format: alternate_output = formatter.format(transport, event.realm, alternate_format, event) else: alternate_output = None rootMessage = MIMEMultipart("related") rootMessage.set_charset(self._charset) proj_name = self.env.project_name trac_version = get_pkginfo(trac.core).get('version', trac.__version__) announcer_version = get_pkginfo(announcerplugin).get( 'version', 'Undefined') rootMessage['X-Mailer'] = 'AnnouncerPlugin v%s on Trac ' \ 'v%s'%(announcer_version, trac_version) rootMessage['X-Trac-Version'] = trac_version rootMessage['X-Announcer-Version'] = announcer_version rootMessage['X-Trac-Project'] = proj_name rootMessage['X-Trac-Announcement-Realm'] = event.realm event_id = self._event_id(event) rootMessage['X-Trac-Announcement-ID'] = event_id if self.set_message_id: msgid = self._message_id(event, event_id, 0) if event.category is not 'created': rootMessage['In-Reply-To'] = msgid rootMessage['References'] = msgid msgid = self._message_id(event, event_id, time.time()) rootMessage['Message-ID'] = msgid rootMessage['Precedence'] = 'bulk' rootMessage['Auto-Submitted'] = 'auto-generated' provided_headers = formatter.format_headers(transport, event.realm, format, event) for key in provided_headers: rootMessage['X-Announcement-%s'%key.capitalize()] = \ to_unicode(provided_headers[key]) rootMessage['Date'] = formatdate() # sanity check if not self._charset.body_encoding: try: dummy = output.encode('ascii') except UnicodeDecodeError: raise TracError(_("Ticket contains non-ASCII chars. " \ "Please change encoding setting")) prefix = self.smtp_subject_prefix if prefix == '__default__': prefix = '[%s]' % self.env.project_name if event.category is not 'created': prefix = 'Re: %s' % prefix if prefix: subject = "%s %s" % (prefix, subject) rootMessage['Subject'] = Header(subject, self._charset) from_header = '"%s" <%s>' % (Header(self.smtp_from_name or proj_name, self._charset), self.smtp_from) rootMessage['From'] = from_header if self.smtp_always_bcc: rootMessage['Bcc'] = self.smtp_always_bcc if self.smtp_to: rootMessage['To'] = '"%s"' % (self.smtp_to) if self.use_public_cc: rootMessage['Cc'] = ', '.join([x[2] for x in recipients if x]) rootMessage['Reply-To'] = self.smtp_replyto rootMessage.preamble = 'This is a multi-part message in MIME format.' if alternate_output: parentMessage = MIMEMultipart('alternative') rootMessage.attach(parentMessage) else: parentMessage = rootMessage if alternate_output: alt_msg_format = 'html' in alternate_format and 'html' or 'plain' msgText = MIMEText(alternate_output, alt_msg_format) parentMessage.attach(msgText) msg_format = 'html' in format and 'html' or 'plain' msgText = MIMEText(output, msg_format) del msgText['Content-Transfer-Encoding'] msgText.set_charset(self._charset) parentMessage.attach(msgText) start = time.time() package = (from_header, [x[2] for x in recipients if x], rootMessage.as_string()) if self.use_threaded_delivery: self.get_delivery_queue().put(package) else: self._transmit(*package) stop = time.time() self.log.debug("EmailDistributor took %s seconds to send."\ %(round(stop-start,2))) def _transmit(self, smtpfrom, addresses, message): # use defaults to make sure connect() is called in the constructor if self.smtp_ssl: smtp = smtplib.SMTP_SSL(host=self.smtp_server, port=self.smtp_port) else: smtp = smtplib.SMTP(host=self.smtp_server, port=self.smtp_port) if self.smtp_debuglevel: smtp.set_debuglevel(self.smtp_debuglevel) if self.use_tls: smtp.ehlo() if not smtp.esmtp_features.has_key('starttls'): raise TracError(_("TLS enabled but server does not support " \ "TLS")) smtp.starttls() smtp.ehlo() if self.smtp_user: smtp.login(self.smtp_user, self.smtp_password) smtp.sendmail(smtpfrom, addresses, message) smtp.quit() # IAnnouncementDistributor def get_announcement_preference_boxes(self, req): yield "email", "E-Mail Format" def render_announcement_preference_box(self, req, panel): transport = self.get_distribution_transport() supported_realms = {} for formatter in self.formatters: if formatter.get_format_transport() == transport: for realm in formatter.get_format_realms(transport): if realm not in supported_realms: supported_realms[realm] = set() supported_realms[realm].update( formatter.get_format_styles(transport, realm)) if req.method == "POST": for realm in supported_realms: opt = req.args.get('email_format_%s' % realm, False) if opt: req.session['announcer_email_format_%s' % realm] = opt prefs = {} for realm in supported_realms: prefs[realm] = req.session.get('announcer_email_format_%s' % realm, None) or self._get_default_format() data = dict( realms=supported_realms, preferences=prefs, ) return "prefs_announcer_email.html", data
class HackergotchiModule(Component): """A stream filter to add hackergotchi emblems to the timeline.""" providers = OrderedExtensionsOption( 'hackergotchi', 'providers', IHackergotchiProvider, default='GravatarHackergotchiProvider, IdenticonHackergotchiProvider') implements(ITemplateStreamFilter, ITemplateProvider) anon_re = re.compile('([^<]+?)\s+<([^>]+)>', re.U) # ITemplateStreamFilter methods def filter_stream(self, req, method, filename, stream, data): if req.path_info.startswith('/timeline'): closure_state = [0] cache = {} def f(stream): # Update the closed value n = closure_state[0] closure_state[0] += 1 # Extract the user information author = data['events'][n]['author'].strip() user_info = cache.get(author) if user_info is not None: author, name, email = user_info else: db = self.env.get_db_cnx() user_info = self._get_info(author, db) cache[author] = user_info author, name, email = user_info # Try to find a provider for provider in self.providers: href = provider.get_hackergotchi(req.href, author, name, email) if href is not None: break else: href = req.href.chrome('hackergotchi', 'default.png') # Build our element elm = tag.img(src=href, alt='Hackergotchi for %s' % author, class_='hackergotchi') # Output the combined stream return itertools.chain(elm.generate(), stream) stream |= Transformer( '//div[@id="content"]/dl/dt/a/span[@class="time"]').filter(f) add_stylesheet(req, 'hackergotchi/hackergotchi.css') return stream # ITemplateProvider methods def get_htdocs_dirs(self): yield 'hackergotchi', resource_filename(__name__, 'htdocs') def get_templates_dirs(self): #return [resource_filename(__name__, 'templates')] return [] # Internal methods def _get_info(self, author, db): if author == 'anonymous': # Don't even bother trying for "anonymous" return author, None, None md = self.anon_re.match(author) if md: # name <email> return 'anonymous', md.group(1), md.group(2) cursor = db.cursor() cursor.execute( 'SELECT name, value FROM session_attribute WHERE sid=%s AND authenticated=%s', (author, 1)) rows = cursor.fetchall() if rows: # Authenticated user, with session name = email = None for key, value in rows: if key == 'name': name = value elif key == 'email': email = value if name or email: return author, name, email else: return author, None, None # Assume anonymous user from this point on if '@' in author: # Likely an email address return 'anonymous', None, author # See if there is a default domain domain = self.config.get('notification', 'smtp_default_domain') if domain and ' ' not in author: return author, None, author + '@' + domain return 'anonymous', author, None
class RelationsSystem(Component): PARENT_RELATION_TYPE = 'parent' CHILDREN_RELATION_TYPE = 'children' changing_listeners = ExtensionPoint(IRelationChangingListener) all_validators = ExtensionPoint(IRelationValidator) global_validators = OrderedExtensionsOption( 'bhrelations', 'global_validators', IRelationValidator, 'NoSelfReferenceValidator, ExclusiveValidator, BlockerValidator', include_missing=False, doc="""Validators used to validate all relations, regardless of their type.""") duplicate_relation_type = Option( 'bhrelations', 'duplicate_relation', 'duplicateof', "Relation type to be used with the resolve as duplicate workflow.") def __init__(self): links, labels, validators, blockers, copy_fields, exclusive = \ self._parse_config() self._links = links self._labels = labels self._validators = validators self._blockers = blockers self._copy_fields = copy_fields self._exclusive = exclusive self.link_ends_map = {} for end1, end2 in self.get_ends(): self.link_ends_map[end1] = end2 if end2 is not None: self.link_ends_map[end2] = end1 def get_ends(self): return self._links def add(self, source_resource_instance, destination_resource_instance, relation_type, comment=None, author=None, when=None): source = ResourceIdSerializer.get_resource_id_from_instance( self.env, source_resource_instance) destination = ResourceIdSerializer.get_resource_id_from_instance( self.env, destination_resource_instance) if relation_type not in self.link_ends_map: raise UnknownRelationType(relation_type) if when is None: when = datetime.now(utc) relation = Relation(self.env) relation.source = source relation.destination = destination relation.type = relation_type relation.comment = comment relation.author = author relation.when = when self.add_relation(relation) return relation def get_reverted_relation(self, relation): """Return None if relation is one way""" other_end = self.link_ends_map[relation.type] if other_end: return relation.clone_reverted(other_end) def add_relation(self, relation): self.validate(relation) with self.env.db_transaction: relation.insert() reverted_relation = self.get_reverted_relation(relation) if reverted_relation: reverted_relation.insert() for listener in self.changing_listeners: listener.adding_relation(relation) from bhrelations.notification import RelationNotifyEmail RelationNotifyEmail(self.env).notify(relation) def delete(self, relation_id, when=None): if when is None: when = datetime.now(utc) relation = Relation.load_by_relation_id(self.env, relation_id) source = relation.source destination = relation.destination relation_type = relation.type with self.env.db_transaction: cloned_relation = relation.clone() relation.delete() other_end = self.link_ends_map[relation_type] if other_end: reverted_relation = Relation(self.env, keys=dict( source=destination, destination=source, type=other_end, )) reverted_relation.delete() for listener in self.changing_listeners: listener.deleting_relation(cloned_relation, when) from bhrelations.notification import RelationNotifyEmail RelationNotifyEmail(self.env).notify(cloned_relation, deleted=when) def delete_resource_relations(self, resource_instance): sql = "DELETE FROM " + Relation.get_table_name() + \ " WHERE source=%s OR destination=%s" full_resource_id = ResourceIdSerializer.get_resource_id_from_instance( self.env, resource_instance) with self.env.db_transaction as db: db(sql, (full_resource_id, full_resource_id)) def _debug_select(self): """The method is used for debug purposes""" sql = "SELECT id, source, destination, type FROM bloodhound_relations" with self.env.db_query as db: return [db(sql)] def get_relations(self, resource_instance): relation_list = [] for relation in self._select_relations_for_resource_instance( resource_instance): relation_list.append( dict( relation_id=relation.get_relation_id(), destination_id=relation.destination, destination=ResourceIdSerializer.get_resource_by_id( relation.destination), type=relation.type, comment=relation.comment, when=relation.when, author=relation.author, )) return relation_list def _select_relations_for_resource_instance(self, resource): resource_full_id = ResourceIdSerializer.get_resource_id_from_instance( self.env, resource) return self._select_relations(resource_full_id) def _select_relations(self, source, resource_type=None): #todo: add optional paging for possible umbrella tickets with #a lot of child tickets where = dict(source=source) if resource_type: where["type"] = resource_type order_by = ["destination"] else: order_by = ["type", "destination"] return Relation.select(self.env, where=where, order_by=order_by) def _parse_config(self): links = [] labels = {} validators = {} blockers = {} copy_fields = {} exclusive = set() config = self.config[RELATIONS_CONFIG_NAME] for name in [ option for option, _ in config.options() if '.' not in option ]: reltypes = config.getlist(name) if not reltypes: continue if len(reltypes) == 1: reltypes += [None] links.append(tuple(reltypes)) custom_validators = self._parse_validators(config, name) for rel in filter(None, reltypes): labels[rel] = \ config.get(rel + '.label') or rel.capitalize() blockers[rel] = \ config.getbool(rel + '.blocks', default=False) if config.getbool(rel + '.exclusive'): exclusive.add(rel) validators[rel] = custom_validators # <end>.copy_fields may be absent or intentionally set empty. # config.getlist() will return [] in either case, so check that # the key is present before assigning the value cf_key = '%s.copy_fields' % rel if cf_key in config: copy_fields[rel] = config.getlist(cf_key) return links, labels, validators, blockers, copy_fields, exclusive def _parse_validators(self, section, name): custom_validators = set('%sValidator' % validator for validator in set( section.getlist(name + '.validators', [], ',', True))) validators = [] if custom_validators: for impl in self.all_validators: if impl.__class__.__name__ in custom_validators: validators.append(impl) return validators def validate(self, relation): """ Validate the relation using the configured validators. Validation is always run on the relation with master type. """ backrel = self.get_reverted_relation(relation) if backrel and (backrel.type, relation.type) in self._links: relation = backrel for validator in self.global_validators: validator.validate(relation) for validator in self._validators.get(relation.type, ()): validator.validate(relation) def is_blocker(self, relation_type): return self._blockers[relation_type] def render_relation_type(self, end): return self._labels[end] def get_relation_types(self): return self._labels def find_blockers(self, resource_instance, is_blocker_method): # tbd: do we blocker finding to be recursive all_blockers = [] for relation in self._select_relations_for_resource_instance( resource_instance): if self.is_blocker(relation.type): resource = ResourceIdSerializer.get_resource_by_id( relation.destination) resource_instance = is_blocker_method(resource) if resource_instance is not None: all_blockers.append(resource_instance) # blockers = self._recursive_find_blockers( # relation, is_blocker_method) # if blockers: # all_blockers.extend(blockers) return all_blockers def get_resource_name(self, resource_id): resource = ResourceIdSerializer.get_resource_by_id(resource_id) return get_resource_shortname(self.env, resource)
class BloodhoundSearchApi(Component): """Implements core indexing functionality, provides methods for searching, adding and deleting documents from index. """ implements(IEnvironmentSetupParticipant, ISupportMultiProductEnvironment) def __init__(self, *args, **kwargs): import pkg_resources locale_dir = pkg_resources.resource_filename(__name__, 'locale') add_domain(self.env.path, locale_dir) super(BloodhoundSearchApi, self).__init__(*args, **kwargs) backend = ExtensionOption( 'bhsearch', 'search_backend', ISearchBackend, 'WhooshBackend', 'Name of the component implementing Bloodhound Search backend \ interface: ISearchBackend.', doc_domain='bhsearch') parser = ExtensionOption( 'bhsearch', 'query_parser', IQueryParser, 'DefaultQueryParser', 'Name of the component implementing Bloodhound Search query \ parser.', doc_domain='bhsearch') index_pre_processors = OrderedExtensionsOption( 'bhsearch', 'index_preprocessors', IDocIndexPreprocessor, ['SecurityPreprocessor'], include_missing=True, ) result_post_processors = ExtensionPoint(IResultPostprocessor) query_processors = ExtensionPoint(IQueryPreprocessor) index_participants = MultiProductExtensionPoint(IIndexParticipant) def query(self, query, sort=None, fields=None, filter=None, facets=None, pagenum=1, pagelen=20, highlight=False, highlight_fields=None, context=None): """Return query result from an underlying search backend. Arguments: :param query: query string e.g. “bla status:closed” or a parsed representation of the query. :param sort: optional sorting :param boost: optional list of fields with boost values e.g. {“id”: 1000, “subject” :100, “description”:10}. :param filter: optional list of terms. Usually can be cached by underlying search framework. For example {“type”: “wiki”} :param facets: optional list of facet terms, can be field or expression :param page: paging support :param pagelen: paging support :param highlight: highlight matched terms in fields :param highlight_fields: list of fields to highlight :param context: request context :return: result QueryResult """ # pylint: disable=too-many-locals self.env.log.debug("Receive query request: %s", locals()) parsed_query = self.parser.parse(query, context) parsed_filters = self.parser.parse_filters(filter) # TODO: add query parsers and meta keywords post-parsing # TODO: apply security filters query_parameters = dict( query=parsed_query, query_string=query, sort=sort, fields=fields, filter=parsed_filters, facets=facets, pagenum=pagenum, pagelen=pagelen, highlight=highlight, highlight_fields=highlight_fields, ) for query_processor in self.query_processors: query_processor.query_pre_process(query_parameters, context) query_result = self.backend.query(**query_parameters) for post_processor in self.result_post_processors: post_processor.post_process(query_result) query_result.debug["api_parameters"] = query_parameters return query_result def start_operation(self): return self.backend.start_operation() def rebuild_index(self): """Rebuild underlying index""" self.log.info('Rebuilding the search index.') self.backend.recreate_index() with self.backend.start_operation() as operation_context: doc = None try: for participant in self.index_participants: self.log.info( "Reindexing resources provided by %s in product %s" % (participant.__class__.__name__, getattr(participant.env.product, 'name', "''"))) docs = participant.get_entries_for_index() for doc in docs: self.log.debug("Indexing document %s:%s/%s" % ( doc.get('product'), doc['type'], doc['id'], )) self.add_doc(doc, operation_context) self.log.info("Reindexing complete.") except Exception, ex: self.log.error(ex) if doc: self.log.error("Doc that triggers the error: %s" % doc) raise
class EmailDistributor(Component): implements(IAnnouncementDistributor) formatters = ExtensionPoint(IAnnouncementFormatter) decorators = ExtensionPoint(IAnnouncementEmailDecorator) resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', IEmailAddressResolver, 'SpecifiedEmailResolver, SessionEmailResolver, ' 'DefaultDomainEmailResolver', """Comma seperated list of email resolver components in the order they will be called. If an email address is resolved, the remaining resolvers will not be called. """) email_sender = ExtensionOption('announcer', 'email_sender', IEmailSender, 'SmtpEmailSender', """Name of the component implementing `IEmailSender`. This component is used by the announcer system to send emails. Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided. """) enabled = BoolOption('announcer', 'email_enabled', True, """Enable email notification.""") email_from = Option('announcer', 'email_from', 'trac@localhost', """Sender address to use in notification emails.""") from_name = Option('announcer', 'email_from_name', '', """Sender name to use in notification emails.""") reply_to = Option('announcer', 'email_replyto', 'trac@localhost', """Reply-To address to use in notification emails.""") mime_encoding = Option('announcer', 'mime_encoding', 'base64', """Specifies the MIME encoding scheme for emails. Valid options are 'base64' for Base64 encoding, 'qp' for Quoted-Printable, and 'none' for no encoding. Note that the no encoding means that non-ASCII characters in text are going to cause problems with notifications. """) use_public_cc = BoolOption('announcer', 'use_public_cc', 'false', """Recipients can see email addresses of other CC'ed recipients. If this option is disabled (the default), recipients are put on BCC """) # used in email decorators, but not here subject_prefix = Option('announcer', 'email_subject_prefix', '__default__', """Text to prepend to subject line of notification emails. If the setting is not defined, then the [$project_name] prefix. If no prefix is desired, then specifying an empty option will disable it. """) to_default = 'undisclosed-recipients: ;' to = Option('announcer', 'email_to', to_default, 'Default To: field') use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery', False, """Do message delivery in a separate thread. Enabling this will improve responsiveness for requests that end up with an announcement being sent over email. It requires building Python with threading support enabled-- which is usually the case. To test, start Python and type 'import threading' to see if it raises an error. """) default_email_format = Option('announcer', 'default_email_format', 'text/plain', """The default mime type of the email notifications. This can be overridden on a per user basis through the announcer preferences panel. """) rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '', """A whitelist pattern to match any address to before adding to recipients list. """) rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '', """A whitelist pattern to match any address, that should be considered local. This will be evaluated only if msg encryption is set too. Recipients with matching email addresses will continue to receive unencrypted email messages. """) crypto = Option('announcer', 'email_crypto', '', """Enable cryptographically operation on email msg body. Empty string, the default for unset, disables all crypto operations. Valid values are: sign sign msg body with given privkey encrypt encrypt msg body with pubkeys of all recipients sign,encrypt sign, than encrypt msg body """) # get GnuPG configuration options gpg_binary = Option('announcer', 'gpg_binary', 'gpg', """GnuPG binary name, allows for full path too. Value 'gpg' is same default as in python-gnupg itself. For usual installations location of the gpg binary is auto-detected. """) gpg_home = Option('announcer', 'gpg_home', '', """Directory containing keyring files. In case of wrong configuration missing keyring files without content will be created in the configured location, provided necessary write permssion is granted for the corresponding parent directory. """) private_key = Option('announcer', 'gpg_signing_key', None, """Keyid of private key (last 8 chars or more) used for signing. If unset, a private key will be selected from keyring automagicly. The password must be available i.e. provided by running gpg-agent or empty (bad security). On failing to unlock the private key, msg body will get emptied. """) def __init__(self): self.enigma = None self.delivery_queue = None self._init_pref_encoding() def get_delivery_queue(self): if not self.delivery_queue: self.delivery_queue = Queue.Queue() thread = DeliveryThread(self.delivery_queue, self.send) thread.start() return self.delivery_queue # IAnnouncementDistributor methods def transports(self): yield 'email' def formats(self, transport, realm): """Find valid formats for transport and realm.""" formats = {} for f in self.formatters: for style in f.styles(transport, realm): formats[style] = f self.log.debug("EmailDistributor has found the following formats " "capable of handling '%s' of '%s': %s", transport, realm, ', '.join(formats.keys())) if not formats: self.log.error("EmailDistributor is unable to continue without " "supporting formatters.") return formats def distribute(self, transport, recipients, event): found = False for supported_transport in self.transports(): if supported_transport == transport: found = True if not self.enabled or not found: self.log.debug("EmailDistributor email_enabled set to false") return formats = self.formats(transport, event.realm) if not formats: self.log.error("EmailDistributor No formats found for %s %s", transport, event.realm) return msgdict = {} msgdict_encrypt = {} msg_pubkey_ids = [] # compile pattern before use for better performance rcpt_allow_re = re.compile(self.rcpt_allow_regexp) rcpt_local_re = re.compile(self.rcpt_local_regexp) if self.crypto != '': self.log.debug("EmailDistributor attempts crypto operation.") self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home) for name, authed, address in recipients: fmt = name and \ self._get_preferred_format(event.realm, name, authed) or \ self._get_default_format() old_fmt = fmt if fmt not in formats: self.log.debug("EmailDistributor format %s not available " "for %s %s, looking for an alternative", fmt, transport, event.realm) # If the fmt is not available for this realm, then try to find # an alternative fmt = None for f in formats.values(): fmt = f.alternative_style_for( transport, event.realm, old_fmt) if fmt: break if not fmt: self.log.error("EmailDistributor was unable to find a " "formatter for format %s", old_fmt) continue resolver = None if name and not address: # figure out what the addr should be if it's not defined for resolver in self.resolvers: address = resolver.get_address_for_session(name, authed) if address: break if address: self.log.debug("EmailDistributor found the address '%s' " "for '%s (%s)' via: %s", address, name, authed and 'authenticated' or 'not authenticated', resolver.__class__.__name__) # ok, we found an addr, add the message # but wait, check for allowed rcpt first, if set if rcpt_allow_re.search(address) is not None: # check for local recipients now local_match = rcpt_local_re.search(address) if self.crypto in ['encrypt', 'sign,encrypt'] and \ local_match is None: # search available public keys for matching UID pubkey_ids = self.enigma.get_pubkey_ids(address) if pubkey_ids > 0: msgdict_encrypt.setdefault(fmt, set())\ .add((name, authed, address)) msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids self.log.debug("EmailDistributor got pubkeys " "for %s: %s", address, pubkey_ids) else: self.log.debug("EmailDistributor dropped %s " "after missing pubkey with " "corresponding address %s in any " "UID", name, address) else: msgdict.setdefault(fmt, set())\ .add((name, authed, address)) if local_match is not None: self.log.debug("EmailDistributor expected local " "delivery for %s to: %s", name, address) else: self.log.debug("EmailDistributor dropped %s for not " "matching allowed recipient pattern %s", address, self.rcpt_allow_regexp) else: self.log.debug("EmailDistributor was unable to find an " "address for: %s (%s)", name, authed and 'authenticated' or 'not authenticated') for k, v in msgdict.items(): if not v or not formats.get(k): continue fmt = formats[k] self.log.debug("EmailDistributor is sending event as '%s' to: " "%s", fmt, ', '.join(x[2] for x in v)) self._do_send(transport, event, k, v, fmt) for k, v in msgdict_encrypt.items(): if not v or not formats.get(k): continue fmt = formats[k] self.log.debug("EmailDistributor is sending encrypted info on " "event as '%s' to: %s", fmt, ', '.join(x[2] for x in v)) self._do_send(transport, event, k, v, formats[k], msg_pubkey_ids) def _get_default_format(self): return self.default_email_format def _get_preferred_format(self, realm, sid, authenticated): if authenticated is None: authenticated = 0 # Format is unified for all subscriptions of a user. result = Subscription.find_by_sid_and_distributor( self.env, sid, authenticated, 'email') if result: chosen = result[0]['format'] self.log.debug("EmailDistributor determined the preferred format" " for '%s (%s)' is: %s", sid, authenticated and 'authenticated' or 'not authenticated', chosen) return chosen else: return self._get_default_format() def _init_pref_encoding(self): self._charset = Charset() self._charset.input_charset = 'utf-8' pref = self.mime_encoding.lower() if pref == 'base64': self._charset.header_encoding = BASE64 self._charset.body_encoding = BASE64 self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref in ['qp', 'quoted-printable']: self._charset.header_encoding = QP self._charset.body_encoding = QP self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref == 'none': self._charset.header_encoding = None self._charset.body_encoding = None self._charset.input_codec = None self._charset.output_charset = 'ascii' else: raise TracError(_("Invalid email encoding setting: %(pref)s", pref=pref)) def _message_id(self, realm): """Generate a predictable, but sufficiently unique message ID.""" modtime = time.time() rand = random.randint(0, 32000) s = '%s.%d.%d.%s' % (self.env.project_url, modtime, rand, realm.encode('ascii', 'ignore')) dig = hashlib.md5(s).hexdigest() host = self.email_from[self.email_from.find('@') + 1:] msgid = '<%03d.%s@%s>' % (len(s), dig, host) return msgid def _filter_recipients(self, rcpt): return rcpt def _do_send(self, transport, event, format, recipients, formatter, pubkey_ids=None): pubkey_ids = pubkey_ids or [] # Prepare sender for use in IEmailSender component and message header. from_header = formataddr( (self.from_name and self.from_name or self.env.project_name, self.email_from) ) headers = dict() headers['Message-ID'] = self._message_id(event.realm) headers['Date'] = formatdate() headers['From'] = from_header headers['Reply-To'] = self.reply_to recip_adds = [x[2] for x in recipients if x] if self.use_public_cc: headers['Cc'] = ', '.join(recip_adds) else: # Use localized Bcc: hint for default To: content. if self.to == self.to_default: headers['To'] = _("undisclosed-recipients: ;") else: headers['To'] = '"%s"' % self.to if self.to: recip_adds += [self.to] if not recip_adds: self.log.debug("EmailDistributor stopped (no recipients).") return self.log.debug("All email recipients: %s", recip_adds) root_message = MIMEMultipart('related') # Write header data into message object. for k, v in headers.iteritems(): set_header(root_message, k, v) output = formatter.format(transport, event.realm, format, event) # DEVEL: Currently crypto operations work with format text/plain only. alternate_output = None alternate_style = [] if self.crypto != '' and pubkey_ids: if self.crypto == 'sign': output = self.enigma.sign(output, self.private_key) elif self.crypto == 'encrypt': output = self.enigma.encrypt(output, pubkey_ids) elif self.crypto == 'sign,encrypt': output = self.enigma.sign_encrypt(output, pubkey_ids, self.private_key) self.log.debug(output) self.log.debug("EmailDistributor crypto operation successful.") else: alternate_style = formatter.alternative_style_for( transport, event.realm, format ) if alternate_style: alternate_output = formatter.format( transport, event.realm, alternate_style, event ) # Sanity check for suitable encoding setting. if not self._charset.body_encoding: try: output.encode('ascii') except UnicodeDecodeError: raise TracError(_("Ticket contains non-ASCII chars. Please " "change encoding setting")) root_message.preamble = "This is a multi-part message in MIME format." if alternate_output: parent_message = MIMEMultipart('alternative') root_message.attach(parent_message) alt_msg_format = 'html' in alternate_style and 'html' or 'plain' if isinstance(alternate_output, unicode): alternate_output = alternate_output.encode('utf-8') msg_text = MIMEText(alternate_output, alt_msg_format) msg_text.set_charset(self._charset) parent_message.attach(msg_text) else: parent_message = root_message msg_format = 'html' in format and 'html' or 'plain' if isinstance(output, unicode): output = output.encode('utf-8') msg_text = MIMEText(output, msg_format) del msg_text['Content-Transfer-Encoding'] msg_text.set_charset(self._charset) # According to RFC 2046, the last part of a multipart message is best # and preferred. parent_message.attach(msg_text) # DEVEL: Decorators can interfere with crypto operation here. Fix it. decorators = self._get_decorators() if decorators: decorator = decorators.pop() decorator.decorate_message(event, root_message, decorators) package = (from_header, recip_adds, root_message.as_string()) start = time.time() if self.use_threaded_delivery: self.get_delivery_queue().put(package) else: self.send(*package) stop = time.time() self.log.debug("EmailDistributor took %s seconds to send.", round(stop - start, 2)) def send(self, from_addr, recipients, message): """Send message to recipients via e-mail.""" # Ensure the message complies with RFC2822: use CRLF line endings message = CRLF.join(re.split('\r?\n', message)) self.email_sender.send(from_addr, recipients, message) def _get_decorators(self): return self.decorators[:]
class AccountManager(Component): """The AccountManager component handles all user account management methods provided by the IPasswordStore interface. The methods will be handled by the underlying password storage implementation set in trac.ini with the "account-manager.password_format" setting. The "account-manager.password_store" may be an ordered list of password stores. If it is a list, then each password store is queried in turn. """ implements(IAccountChangeListener) _password_store = OrderedExtensionsOption('account-manager', 'password_store', IPasswordStore, include_missing=False) _password_format = Option('account-manager', 'password_format') stores = ExtensionPoint(IPasswordStore) change_listeners = ExtensionPoint(IAccountChangeListener) allow_delete_account = BoolOption( 'account-manager', 'allow_delete_account', True, doc="Allow users to delete their own account.") force_passwd_change = BoolOption( 'account-manager', 'force_passwd_change', True, doc="Force the user to change password when it's reset.") persistent_sessions = BoolOption( 'account-manager', 'persistent_sessions', False, doc="""Allow the user to be remembered across sessions without needing to re-authenticate. This is, user checks a \"Remember Me\" checkbox and, next time he visits the site, he'll be remembered.""") refresh_passwd = BoolOption( 'account-manager', 'refresh_passwd', False, doc="""Re-set passwords on successful authentication. This is most useful to move users to a new password store or enforce new store configuration (i.e. changed hash type), but should be disabled/unset otherwise.""") verify_email = BoolOption('account-manager', 'verify_email', True, doc="Verify the email address of Trac users.") username_char_blacklist = Option( 'account-manager', 'username_char_blacklist', ':[]', doc="""Always exclude some special characters from usernames. This is enforced upon new user registration.""") def __init__(self): # bind the 'acct_mgr' catalog to the specified locale directory locale_dir = resource_filename(__name__, 'locale') add_domain(self.env.path, locale_dir) # Public API def get_users(self): users = [] for store in self._password_store: users.extend(store.get_users()) return users def has_user(self, user): exists = False user = self.handle_username_casing(user) for store in self._password_store: if store.has_user(user): exists = True break continue return exists def has_email(self, email): """Returns whether a user account with that email address exists. Check db directly - email addresses are not backend-specific. """ exists = False db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ SELECT value FROM session_attribute WHERE authenticated=1 AND name='email' AND value=%s """, (email, )) for row in cursor: exists = True break return exists def email_verified(self, user, email): """Returns whether the account and email has been verified. Use with care, as it returns the private token string, if verification is pending. """ if (self.user_known(user) is False or email is None) or email == '': # nothing to check here return None db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ SELECT value FROM session_attribute WHERE sid=%s AND name='email_verification_sent_to' """, (user, )) for row in cursor: self.log.debug('AcctMgr:api:email_verify for user \"' + \ user + '\", email \"' + str(email) + '\": ' + str(row[0])) if row[0] != email: # verification has been sent to different email address return None cursor.execute( """ SELECT value FROM session_attribute WHERE sid=%s AND name='email_verification_token' """, (user, )) for row in cursor: # verification token still unverified self.log.debug('AcctMgr:api:email_verify for user \"' + \ user + '\", email \"' + str(email) + '\": ' + str(row[0])) return row[0] return True def user_known(self, user): """Returns whether the user has ever been authenticated before. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ SELECT * FROM session WHERE authenticated=1 AND sid=%s """, (user, )) for row in cursor: return True return False def last_seen(self, user=None): db = self.env.get_db_cnx() cursor = db.cursor() sql = """ SELECT sid,last_visit FROM session WHERE authenticated=1 """ if user: sql += " AND sid=%s" cursor.execute(sql, (user, )) else: cursor.execute(sql) # Don't pass over the cursor (outside of scope), only it's content. res = [] for row in cursor: res.append(row) return not len(res) == 0 and res or None def set_password(self, user, password, old_password=None): user = self.handle_username_casing(user) store = self.find_user_store(user) if store and not hasattr(store, 'set_password'): raise TracError( _("""The authentication backend for user %s does not support setting the password. """ % user)) elif not store: store = self.get_supporting_store('set_password') if store: if store.set_password(user, password, old_password): self._notify('created', user, password) else: self._notify('password_changed', user, password) else: raise TracError( _("""None of the IPasswordStore components listed in the trac.ini supports setting the password or creating users. """)) def check_password(self, user, password): valid = False user = self.handle_username_casing(user) for store in self._password_store: valid = store.check_password(user, password) if valid: if valid == True and (self.refresh_passwd == True) and \ self.get_supporting_store('set_password'): self._maybe_update_hash(user, password) break return valid def delete_user(self, user): user = self.handle_username_casing(user) db = self.env.get_db_cnx() cursor = db.cursor() # Delete session attributes, session and any custom permissions # set for the user. for table in ['session_attribute', 'session', 'permission']: key = (table == 'permission') and 'username' or 'sid' # Preseed, since variable table and column names are allowed # as SQL arguments (security measure agains SQL injections). sql = """ DELETE FROM %s WHERE %s=%%s """ % (table, key) cursor.execute(sql, (user, )) db.commit() db.close() # Delete from password store self.log.debug('deleted user: %s' % user) store = self.find_user_store(user) if hasattr(store, 'delete_user'): if store and store.delete_user(user): self._notify('deleted', user) def supports(self, operation): try: stores = self.password_store except AttributeError: return False else: if self.get_supporting_store(operation): return True else: return False def password_store(self): try: return self._password_store except AttributeError: # fall back on old "password_format" option fmt = self._password_format for store in self.stores: config_key = getattr(store, 'config_key', None) if config_key is None: continue if config_key() == fmt: return [store] # if the "password_format" is not set re-raise the AttributeError raise password_store = property(password_store) def get_supporting_store(self, operation): """Returns the IPasswordStore that implements the specified operation. None is returned if no supporting store can be found. """ supports = False for store in self.password_store: if hasattr(store, operation): supports = True break continue store = supports and store or None return store def get_all_supporting_stores(self, operation): """Returns a list of stores that implement the specified operation""" stores = [] for store in self.password_store: if hasattr(store, operation): stores.append(store) continue return stores def find_user_store(self, user): """Locates which store contains the user specified. If the user isn't found in any IPasswordStore in the chain, None is returned. """ ignore_auth_case = self.config.getbool('trac', 'ignore_auth_case') user_stores = [] for store in self._password_store: userlist = store.get_users() user_stores.append((store, userlist)) continue user = self.handle_username_casing(user) for store in user_stores: if user in store[1]: return store[0] continue return None def handle_username_casing(self, user): """Enforce lowercase usernames if required. Comply with Trac's own behavior, when case-insensitive user authentication is set to True. """ ignore_auth_case = self.config.getbool('trac', 'ignore_auth_case') return ignore_auth_case and user.lower() or user def _maybe_update_hash(self, user, password): db = self.env.get_db_cnx() cursor = db.cursor() sql = """ SELECT sid FROM session_attribute WHERE sid=%s AND name='password_refreshed' AND value=1 """ cursor.execute(sql, (user, )) if cursor.fetchone() is None: self.log.debug('refresh password for user: %s' % user) store = self.find_user_store(user) pwstore = self.get_supporting_store('set_password') if pwstore.set_password(user, password) == True: # Account re-created according to current settings if store and not (store.delete_user(user) == True): self.log.warn("failed to remove old entry for user '%s'" % user) cursor.execute( """ UPDATE session_attribute SET value='1' WHERE sid=%s AND name='password_refreshed' """, (user, )) cursor.execute(sql, (user, )) if cursor.fetchone() is None: cursor.execute( """ INSERT INTO session_attribute (sid,authenticated,name,value) VALUES (%s,1,'password_refreshed',1) """, (user, )) db.commit() def _notify(self, func, *args): func = 'user_' + func for l in self.change_listeners: getattr(l, func)(*args) # IAccountChangeListener methods def user_created(self, user, password): self.log.info('Created new user: %s' % user) def user_password_changed(self, user, password): self.log.info('Updated password for user: %s' % user) def user_deleted(self, user): self.log.info('Deleted user: %s' % user) def user_password_reset(self, user, email, password): self.log.info('Password reset user: %s, %s' % (user, email)) def user_email_verification_requested(self, user, token): self.log.info('Email verification requested user: %s' % user)
class RequestDispatcher(Component): """Component responsible for dispatching requests to registered handlers.""" authenticators = ExtensionPoint(IAuthenticator) handlers = ExtensionPoint(IRequestHandler) filters = OrderedExtensionsOption( 'trac', 'request_filters', IRequestFilter, doc="""Ordered list of filters to apply to all requests (''since 0.10'').""") default_handler = ExtensionOption( 'trac', 'default_handler', IRequestHandler, 'WikiModule', """Name of the component that handles requests to the base URL. Options include `TimelineModule`, `RoadmapModule`, `BrowserModule`, `QueryModule`, `ReportModule`, `TicketModule` and `WikiModule`. The default is `WikiModule`. (''since 0.9'')""") default_timezone = Option('trac', 'default_timezone', '', """The default timezone to use""") # Public API def authenticate(self, req): for authenticator in self.authenticators: authname = authenticator.authenticate(req) if authname: return authname else: return 'anonymous' def dispatch(self, req): """Find a registered handler that matches the request and let it process it. In addition, this method initializes the HDF data set and adds the web site chrome. """ self.log.debug('Dispatching %r', req) chrome = Chrome(self.env) # Setup request callbacks for lazily-evaluated properties req.callbacks.update({ 'authname': self.authenticate, 'chrome': chrome.prepare_request, 'hdf': self._get_hdf, 'perm': self._get_perm, 'session': self._get_session, 'tz': self._get_timezone, 'form_token': self._get_form_token }) try: try: # Select the component that should handle the request chosen_handler = None try: for handler in self.handlers: if handler.match_request(req): chosen_handler = handler break if not chosen_handler: if not req.path_info or req.path_info == '/': chosen_handler = self.default_handler # pre-process any incoming request, whether a handler # was found or not chosen_handler = self._pre_process_request( req, chosen_handler) except TracError, e: raise HTTPInternalError(e) if not chosen_handler: if req.path_info.endswith('/'): # Strip trailing / and redirect target = req.path_info.rstrip('/').encode('utf-8') if req.query_string: target += '?' + req.query_string req.redirect(req.href + target, permanent=True) raise HTTPNotFound('No handler matched request to %s', req.path_info) req.callbacks['chrome'] = partial(chrome.prepare_request, handler=chosen_handler) # Protect against CSRF attacks: we validate the form token for # all POST requests with a content-type corresponding to form # submissions if req.method == 'POST': ctype = req.get_header('Content-Type') if ctype: ctype, options = cgi.parse_header(ctype) if ctype in ('application/x-www-form-urlencoded', 'multipart/form-data') and \ req.args.get('__FORM_TOKEN') != req.form_token: raise HTTPBadRequest('Missing or invalid form token. ' 'Do you have cookies enabled?') # Process the request and render the template resp = chosen_handler.process_request(req) if resp: if len(resp) == 2: # Clearsilver chrome.populate_hdf(req) template, content_type = \ self._post_process_request(req, *resp) # Give the session a chance to persist changes req.session.save() req.display(template, content_type or 'text/html') else: # Genshi template, data, content_type = \ self._post_process_request(req, *resp) if 'hdfdump' in req.args: req.perm.require('TRAC_ADMIN') # debugging helper - no need to render first from pprint import pprint out = StringIO() pprint(data, out) req.send(out.getvalue(), 'text/plain') else: output = chrome.render_template( req, template, data, content_type) # Give the session a chance to persist changes req.session.save() req.send(output, content_type or 'text/html') else: self._post_process_request(req) except RequestDone: raise except: # post-process the request in case of errors err = sys.exc_info() try: self._post_process_request(req) except RequestDone: raise except Exception, e: self.log.error( "Exception caught while post-processing" " request: %s", exception_to_unicode(e, traceback=True)) raise err[0], err[1], err[2]
class RequestDispatcher(Component): """Component responsible for dispatching requests to registered handlers.""" authenticators = ExtensionPoint(IAuthenticator) handlers = ExtensionPoint(IRequestHandler) filters = OrderedExtensionsOption( 'trac', 'request_filters', IRequestFilter, doc="""Ordered list of filters to apply to all requests (''since 0.10'').""") default_handler = ExtensionOption( 'trac', 'default_handler', IRequestHandler, 'WikiModule', """Name of the component that handles requests to the base URL. Options include `TimelineModule`, `RoadmapModule`, `BrowserModule`, `QueryModule`, `ReportModule` and `NewticketModule` (''since 0.9'').""" ) # Public API def authenticate(self, req): for authenticator in self.authenticators: authname = authenticator.authenticate(req) if authname: return authname else: return 'anonymous' def dispatch(self, req): """Find a registered handler that matches the request and let it process it. In addition, this method initializes the HDF data set and adds the web site chrome. """ # FIXME: For backwards compatibility, should be removed in 0.11 self.env.href = req.href # FIXME in 0.11: self.env.abs_href = Href(self.env.base_url) self.env.abs_href = req.abs_href # Select the component that should handle the request chosen_handler = None early_error = None req.authname = 'anonymous' req.perm = NoPermissionCache() try: if not req.path_info or req.path_info == '/': chosen_handler = self.default_handler else: for handler in self.handlers: if handler.match_request(req): chosen_handler = handler break # Attach user information to the request early, so that # the IRequestFilter can see it while preprocessing if not getattr(chosen_handler, 'anonymous_request', False): try: req.authname = self.authenticate(req) req.perm = PermissionCache(self.env, req.authname) req.session = Session(self.env, req) req.form_token = self._get_form_token(req) except: req.authname = 'anonymous' req.perm = NoPermissionCache() early_error = sys.exc_info() chosen_handler = self._pre_process_request(req, chosen_handler) except: early_error = sys.exc_info() if not chosen_handler and not early_error: early_error = (HTTPNotFound('No handler matched request to %s', req.path_info), None, None) # Prepare HDF for the clearsilver template try: use_template = getattr(chosen_handler, 'use_template', True) req.hdf = None if use_template: chrome = Chrome(self.env) req.hdf = HDFWrapper(loadpaths=chrome.get_all_templates_dirs()) populate_hdf(req.hdf, self.env, req) chrome.populate_hdf(req, chosen_handler) except: req.hdf = None # revert to sending plaintext error if not early_error: raise if early_error: try: self._post_process_request(req) except Exception, e: self.log.exception(e) raise early_error[0], early_error[1], early_error[2] # Process the request and render the template try: try: # Protect against CSRF attacks: we validate the form token for # all POST requests with a content-type corresponding to form # submissions if req.method == 'POST': ctype = req.get_header('Content-Type') if ctype: ctype, options = cgi.parse_header(ctype) if ctype in ('application/x-www-form-urlencoded', 'multipart/form-data') and \ req.args.get('__FORM_TOKEN') != req.form_token: raise HTTPBadRequest('Missing or invalid form token. ' 'Do you have cookies enabled?') resp = chosen_handler.process_request(req) if resp: template, content_type = self._post_process_request( req, *resp) # Give the session a chance to persist changes if req.session: req.session.save() req.display(template, content_type or 'text/html') else: self._post_process_request(req) except RequestDone: raise except: err = sys.exc_info() try: self._post_process_request(req) except Exception, e: self.log.exception(e) raise err[0], err[1], err[2] except PermissionError, e: raise HTTPForbidden(to_unicode(e))
class BloodhoundSearchModule(Component): """Main search page""" implements(IPermissionRequestor, IRequestHandler, ITemplateProvider, IRequestFilter # IWikiSyntaxProvider #todo: implement later ) search_participants = OrderedExtensionsOption( 'bhsearch', 'search_participants', ISearchParticipant, "TicketSearchParticipant, WikiSearchParticipant", include_missing=True) prefix = "all" default_grid_fields = [ IndexFields.PRODUCT, IndexFields.ID, IndexFields.TYPE, IndexFields.TIME, IndexFields.AUTHOR, IndexFields.CONTENT, ] default_facets = ListOption( BHSEARCH_CONFIG_SECTION, prefix + '_default_facets', default=",".join([IndexFields.PRODUCT, IndexFields.TYPE]), doc="""Default facets applied to search view of all resources""", doc_domain='bhsearch') default_view = Option( BHSEARCH_CONFIG_SECTION, prefix + '_default_view', doc="""If true, show grid as default view for specific resource in Bloodhound Search results""", doc_domain='bhsearch') all_grid_fields = ListOption( BHSEARCH_CONFIG_SECTION, prefix + '_default_grid_fields', default=",".join(default_grid_fields), doc="""Default fields for grid view for specific resource""", doc_domain='bhsearch') default_search = BoolOption( BHSEARCH_CONFIG_SECTION, 'is_default', default=False, doc="""Searching from quicksearch uses bhsearch.""", doc_domain='bhsearch') redirect_enabled = BoolOption( BHSEARCH_CONFIG_SECTION, 'enable_redirect', default=False, doc="""Redirect links pointing to trac search to bhsearch""", doc_domain='bhsearch') global_quicksearch = BoolOption( BHSEARCH_CONFIG_SECTION, 'global_quicksearch', default=True, doc="""Quicksearch searches all products, even when used in product env.""", doc_domain='bhsearch') query_suggestions_enabled = BoolOption( BHSEARCH_CONFIG_SECTION, 'query_suggestions', default=True, doc="""Display query suggestions.""", doc_domain='bhsearch') # IPermissionRequestor methods def get_permission_actions(self): return [SEARCH_PERMISSION] # IRequestHandler methods def match_request(self, req): return re.match('^%s' % BHSEARCH_URL, req.path_info) is not None def process_request(self, req): req.perm.assert_permission(SEARCH_PERMISSION) if self._is_opensearch_request(req): return ('opensearch.xml', {}, 'application/opensearchdescription+xml') request_context = RequestContext( self.env, req, self.search_participants, self.default_view, self.all_grid_fields, self.default_facets, self.global_quicksearch, self.query_suggestions_enabled, ) if request_context.requires_redirect: req.redirect(request_context.parameters.create_href(), True) # compatibility with legacy search req.search_query = request_context.parameters.query query_result = BloodhoundSearchApi(self.env).query( request_context.parameters.query, pagenum=request_context.page, pagelen=request_context.pagelen, sort=request_context.sort, fields=request_context.fields, facets=request_context.facets, filter=request_context.query_filter, highlight=True, context=request_context, ) request_context.process_results(query_result) return self._return_data(req, request_context.data) def _is_opensearch_request(self, req): return req.path_info == BHSEARCH_URL + '/opensearch' def _return_data(self, req, data): add_stylesheet(req, 'common/css/search.css') return 'bhsearch.html', data, None # ITemplateProvider methods def get_htdocs_dirs(self): # return [('bhsearch', # pkg_resources.resource_filename(__name__, 'htdocs'))] return [] def get_templates_dirs(self): return [pkg_resources.resource_filename(__name__, 'templates')] # IRequestFilter methods def pre_process_request(self, req, handler): if SEARCH_URLS_RE.match(req.path_info): if self.redirect_enabled: return self return handler def post_process_request(self, req, template, data, content_type): if data is None: return template, data, content_type if self.redirect_enabled: data['search_handler'] = req.href.bhsearch() elif req.path_info.startswith(SEARCH_URL): data['search_handler'] = req.href.search() elif self.default_search or req.path_info.startswith(BHSEARCH_URL): data['search_handler'] = req.href.bhsearch() else: data['search_handler'] = req.href.search() return template, data, content_type
class MapDashboard(Component): implements(IRequestHandler, INavigationContributor) ### configuration options openlayers_url = Option('geo', 'openlayers_url', 'http://openlayers.org/api/2.8-rc2/OpenLayers.js', "URL of OpenLayers JS to use") dashboard_tickets = IntOption( 'geo', 'dashboard_tickets', '6', "number of tickets to display on the dashboard map") display_cloud = BoolOption( 'geo', 'display_cloud', 'true', "whether to display the cloud on the map dashboard") dashboard = ListOption('geo', 'dashboard', 'activeissues', "which viewports to display on the dashboard") marker_style = OrderedExtensionsOption( 'geo', 'marker_style', IMapMarkerStyle, '', include_missing=False, doc="component to use to set feature style") def panels(self): """return the panel configuration""" retval = [] # XXX ugly hack because self.dashboard doesn't return # a list for no apparent reason for panel in self.env.config.getlist('geo', 'dashboard'): defaults = {'label': panel, 'query': None} config = {} for key, default in defaults.items(): config[key] = self.env.config.get('geo', '%s.%s' % (panel, key)) or default if config['query'] is not None: config['id'] = panel retval.append(config) return retval ### methods for IRequestHandler """Extension point interface for request handlers.""" def match_request(self, req): """Return whether the handler wants to process the given request.""" return req.path_info.strip('/') == 'map' def process_request(self, req): """Process the request. For ClearSilver, return a (template_name, content_type) tuple, where `template` is the ClearSilver template to use (either a `neo_cs.CS` object, or the file name of the template), and `content_type` is the MIME type of the content. For Genshi, return a (template_name, data, content_type) tuple, where `data` is a dictionary of substitutions for the template. For both templating systems, "text/html" is assumed if `content_type` is `None`. Note that if template processing should not occur, this method can simply send the response itself and not return anything. """ # get the GeoTicket component assert self.env.is_component_enabled(GeoTicket) geoticket = GeoTicket(self.env) # add the query script add_script(req, 'common/js/query.js') # get the panel configuration config = self.panels() # build the panels panels = [] located_tickets = geoticket.tickets_with_location() for panel in config: # query the tickets query_string = panel['query'] query = Query.from_string(self.env, query_string) # decide the date to sort by if query.order == 'time': date_to_display = 'time_created' else: date_to_display = 'time_changed' results = query.execute(req) n_tickets = len(results) results = [ result for result in results if result['id'] in located_tickets ] locations = [] tickets = [] results = results[:self.dashboard_tickets] for result in results: ticket = Ticket(self.env, result['id']) try: address, (lat, lon) = geoticket.locate_ticket(ticket) content = geoticket.feature_content(req, ticket) # style for the markers style = {} for extension in self.marker_style: style.update(extension.style(ticket, req, **style)) style = style or None locations.append({ 'latitude': lat, 'longitude': lon, 'style': style, 'content': Markup(content) }) tickets.append(ticket) except GeolocationException: continue title = panel['label'] panels.append({ 'title': title, 'id': panel['id'], 'locations': Markup(simplejson.dumps(locations)), 'tickets': tickets, 'n_tickets': n_tickets, 'date_to_display': date_to_display, 'query_href': query.get_href(req.href) }) # add the tag cloud, if enabled cloud = None if self.display_cloud: if TagCloudMacro is None: self.log.warn( "[geo] display_cloud is set but the TagsPlugin is not installed" ) else: formatter = Formatter(self.env, Context.from_request(req)) macro = TagCloudMacro(self.env) cloud = macro.expand_macro(formatter, 'TagCloud', '') add_stylesheet(req, 'tags/css/tractags.css') add_stylesheet(req, 'tags/css/tagcloud.css') # compile data for the genshi template data = dict(panels=panels, cloud=cloud, openlayers_url=self.openlayers_url) return ('mapdashboard.html', data, 'text/html') ### methods for INavigationContributor """Extension point interface for components that contribute items to the navigation. """ def get_active_navigation_item(self, req): """This method is only called for the `IRequestHandler` processing the request. It should return the name of the navigation item that should be highlighted as active/current. """ return 'map' def get_navigation_items(self, req): """Should return an iterable object over the list of navigation items to add, each being a tuple in the form (category, name, text). """ yield ('mainnav', 'map', tag.a('Map', href=req.href.map(), accesskey='M'))
class AccountManager(Component): """The AccountManager component handles all user account management methods provided by the IPasswordStore interface. The methods will be handled by underlying password storage implementations set in trac.ini with the "account-manager.password_store" option. The "account-manager.password_store" may be an ordered list of password stores, and if so, then each password store is queried in turn. """ implements(IAccountChangeListener, IPermissionRequestor, IRequestFilter) change_listeners = ExtensionPoint(IAccountChangeListener) # All checks, not only the configured ones (see self.register_checks). checks = ExtensionPoint(IAccountRegistrationInspector) password_stores = OrderedExtensionsOption( 'account-manager', 'password_store', IPasswordStore, include_missing=False, doc=N_("Ordered list of password stores, queried in turn.")) register_checks = OrderedExtensionsOption( 'account-manager', 'register_check', IAccountRegistrationInspector, default="""BasicCheck, EmailCheck, BotTrapCheck, RegExpCheck, UsernamePermCheck""", include_missing=False, doc="""Ordered list of IAccountRegistrationInspector's to use for registration checks.""") # All stores, not only the configured ones (see self.password_stores). stores = ExtensionPoint(IPasswordStore) allow_delete_account = BoolOption( 'account-manager', 'allow_delete_account', True, doc="Allow users to delete their own account.") force_passwd_change = BoolOption( 'account-manager', 'force_passwd_change', True, doc="Force the user to change password when it's reset.") persistent_sessions = BoolOption( 'account-manager', 'persistent_sessions', False, doc="""Allow the user to be remembered across sessions without needing to re-authenticate. This is, user checks a \"Remember Me\" checkbox and, next time he visits the site, he'll be remembered.""") refresh_passwd = BoolOption( 'account-manager', 'refresh_passwd', False, doc="""Re-set passwords on successful authentication. This is most useful to move users to a new password store or enforce new store configuration (i.e. changed hash type), but should be disabled/unset otherwise.""") username_char_blacklist = Option( 'account-manager', 'username_char_blacklist', ':[]', doc="""Always exclude some special characters from usernames. This is enforced upon new user registration.""") def __init__(self): # Bind the 'acct_mgr' catalog to the specified locale directory. locale_dir = resource_filename(__name__, 'locale') add_domain(self.env.path, locale_dir) # Public API def get_users(self): """Get usernames from all active stores. Because we allow concurrent active stores, and some stores even don't warrant uniqueness within itself, multiple usernames should be expected. """ users = [] for store in self.password_stores: users.extend(store.get_users()) return users def has_user(self, user): exists = False user = self.handle_username_casing(user) for store in self.password_stores: if store.has_user(user): exists = True break continue return exists def set_password(self, user, password, old_password=None, overwrite=True): user = self.handle_username_casing(user) store = self.find_user_store(user) if store and not hasattr(store, 'set_password'): raise TracError( _("""The authentication backend for user %s does not support setting the password. """ % user)) elif not store: store = self.get_supporting_store('set_password') if store: try: res = store.set_password(user, password, old_password, overwrite) except TypeError: # Support former method signature - overwrite unconditionally. res = None if overwrite or not store.has_user(user): res = store.set_password(user, password, old_password) if res: self._notify('created', user, password) elif not overwrite: raise TracError( _("Password for user %s existed, couldn't create." % user)) else: self._notify('password_changed', user, password) else: raise TracError( _("""None of the IPasswordStore components listed in the trac.ini supports setting the password or creating users. """)) return res def check_password(self, user, password): valid = False user = self.handle_username_casing(user) for store in self.password_stores: valid = store.check_password(user, password) if valid: if valid == True and (self.refresh_passwd == True) and \ self.get_supporting_store('set_password'): self._maybe_update_hash(user, password) break return valid def delete_user(self, user): user = self.handle_username_casing(user) # Delete credentials from password store. store = self.find_user_store(user) del_method = getattr(store, 'delete_user', None) if callable(del_method): del_method(user) # Delete session attributes, session and any custom permissions # set for the user. from acct_mgr.model import delete_user delete_user(self.env, user) self._notify('deleted', user) def supports(self, operation): try: stores = self.password_stores except AttributeError: return False else: if self.get_supporting_store(operation): return True else: return False def get_supporting_store(self, operation): """Returns the IPasswordStore that implements the specified operation. None is returned if no supporting store can be found. """ supports = False for store in self.password_stores: if hasattr(store, operation): supports = True break continue store = supports and store or None return store def get_all_supporting_stores(self, operation): """Returns a list of stores that implement the specified operation""" stores = [] for store in self.password_stores: if hasattr(store, operation): stores.append(store) continue return stores def find_user_store(self, user): """Locates which store contains the user specified. If the user isn't found in any IPasswordStore in the chain, None is returned. """ user_stores = [] for store in self.password_stores: userlist = store.get_users() user_stores.append((store, userlist)) continue user = self.handle_username_casing(user) for store in user_stores: if user in store[1]: return store[0] continue return None def handle_username_casing(self, user): """Enforce lowercase usernames if required. Comply with Trac's own behavior, when case-insensitive user authentication is set to True. """ ignore_auth_case = self.config.getbool('trac', 'ignore_auth_case') return ignore_auth_case and user.lower() or user def validate_account(self, req, create=False): """Run configured registration checks. Optionally create a new account on success. """ for inspector in self.register_checks: inspector.validate_registration(req) if create: self._create_user(req) def _create_user(self, req): """Set password and prime a new authenticated Trac session.""" email = req.args.get('email', '').strip() name = req.args.get('name', '').strip() username = self.handle_username_casing( req.args.get('username', '').strip()) # Create the user in the configured (primary) password store. if self.set_password(username, req.args.get('password'), None, False): # Result of a successful account creation request is a made-up # authenticated session, that a new user can refer to later on. from acct_mgr.model import prime_auth_session, set_user_attribute prime_auth_session(self.env, username) # Save attributes for the user with reference to that session ID. for attribute in ('name', 'email'): value = req.args.get(attribute) if not value: continue set_user_attribute(self.env, username, attribute, value) def _maybe_update_hash(self, user, password): from acct_mgr.model import get_user_attribute, set_user_attribute if get_user_attribute(self.env, user, 1, 'password_refreshed', 1) == [0]: self.log.debug("Refresh password for user: %s" % user) store = self.find_user_store(user) pwstore = self.get_supporting_store('set_password') if pwstore.set_password(user, password) == True: # Account re-created according to current settings. if store and not (store.delete_user(user) == True): self.log.warn("Failed to remove old entry for user: %s" % user) set_user_attribute(self.env, user, 'password_refreshed', 1) def _notify(self, mod, *args): mod = '_'.join(['user', mod]) for listener in self.change_listeners: # Support divergent account change listener implementations too. try: getattr(listener, mod)(*args) except AttributeError: self.log.warn( 'IAccountChangeListener %s does not support method %s' % (listener.__class__.__name__, mod)) # IAccountChangeListener methods def user_created(self, user, password): self.log.info("Created new user: %s" % user) def user_id_changed(self, old_uid, new_uid): self.log.info("Changed user id: from '%s' to '%s'" % (old_uid, new_uid)) def user_password_changed(self, user, password): self.log.info("Updated password for user: %s" % user) def user_deleted(self, user): self.log.info("Deleted user: %s" % user) def user_password_reset(self, user, email, password): self.log.info("Password reset for user: %s, %s" % (user, email)) def user_email_verification_requested(self, user, token): self.log.info("Email verification requested for user: %s" % user) def user_registration_approval_required(self, user): self.log.info("Registration approval required for user: %s" % user) # IRequestFilter methods def pre_process_request(self, req, handler): if not req.session.authenticated or \ req.perm.has_permission('ACCTMGR_USER_ADMIN'): # Permissions for anonymous and admin users remain unchanged. return handler if 'approval' in req.session: # Account approval not granted, remove elevated permissions. req.perm = PermissionCache(self.env) self.log.debug( "AccountManager.pre_process_request: Permissions for '%s' " "stripped (account approval %s)" % (req.authname, req.session['approval'])) return handler def post_process_request(self, req, template, data, content_type): return template, data, content_type # IPermissionRequestor methods def get_permission_actions(self): action = [ 'ACCTMGR_CONFIG_ADMIN', 'ACCTMGR_USER_ADMIN', 'EMAIL_VIEW', 'USER_VIEW' ] actions = [('ACCTMGR_ADMIN', action), action[0], (action[1], action[2:]), action[3]] return actions
class XmppDistributor(Component): """Distribute announcements to XMPP clients.""" implements(IAnnouncementDistributor) formatters = ExtensionPoint(IAnnouncementFormatter) resolvers = OrderedExtensionsOption( 'announcer', 'xmpp_resolvers', IAnnouncementAddressResolver, 'SpecifiedXmppResolver', """Comma seperated list of xmpp resolver components in the order they will be called. If an xmpp address is resolved, the remaining resolvers will no be called. """) default_format = Option('announcer', 'default_xmpp_format', 'text/plain', """Default format for xmpp messages.""") server = Option( 'xmpp', 'server', None, """XMPP server hostname to use for jabber notifications.""") port = IntOption('xmpp', 'port', 5222, """XMPP server port to use for jabber notification.""") user = Option('xmpp', 'user', 'trac@localhost', """Sender address to use in xmpp message.""") resource = Option('xmpp', 'resource', 'TracAnnouncerPlugin', """Sender resource to use in xmpp message.""") password = Option('xmpp', 'password', None, """Password for XMPP server.""") use_threaded_delivery = BoolOption( 'announcer', 'use_threaded_delivery', False, """If true, the actual delivery of the message will occur in a separate thread. Enabling this will improve responsiveness for requests that end up with an announcement being sent over email. It requires building Python with threading support enabled-- which is usually the case. To test, start Python and type 'import threading' to see if it raises an error. """) def __init__(self): self.connections = {} self.delivery_queue = None self.xmpp_format_setting = SubscriptionSetting(self.env, 'xmpp_format', self.default_format) def get_delivery_queue(self): if not self.delivery_queue: self.delivery_queue = Queue.Queue() thread = DeliveryThread(self.delivery_queue, self.send) thread.start() return self.delivery_queue # IAnnouncementDistributor def transports(self): yield "xmpp" def distribute(self, transport, recipients, event): self.log.info('XmppDistributor called') if transport != 'xmpp': return fmtdict = self._formats(transport, event.realm) if not fmtdict: self.log.error("XmppDistributor No formats found for %s %s" % (transport, event.realm)) return msgdict = {} for name, authed, addr in recipients: fmt = name and \ self._get_preferred_format(name, event.realm) if fmt not in fmtdict: self.log.debug(("XmppDistributor format %s not available " + "for %s %s, looking for an alternative") % (fmt, transport, event.realm)) # If the fmt is not available for this realm, then try to find # an alternative oldfmt = fmt fmt = None for f in fmtdict.values(): fmt = f.alternative_style_for(transport, event.realm, oldfmt) if fmt: break if not fmt: self.log.error( "XmppDistributor was unable to find a formatter " + "for format %s" % k) continue # TODO: This won't work with multiple distributors #rslvr = None # figure out what the addr should be if it's not defined #for rslvr in self.resolvers: # addr = rslvr.get_address_for_name(name, authed) # if addr: break rslvr = SpecifiedXmppResolver(self.env) addr = rslvr.get_address_for_name(name, authed) if addr: self.log.debug("XmppDistributor found the " \ "address '%s' for '%s (%s)' via: %s"%( addr, name, authed and \ 'authenticated' or 'not authenticated', rslvr.__class__.__name__)) # ok, we found an addr, add the message msgdict.setdefault(fmt, set()).add((name, authed, addr)) else: self.log.debug("XmppDistributor was unable to find an " \ "address for: %s (%s)"%(name, authed and \ 'authenticated' or 'not authenticated')) for k, v in msgdict.items(): if not v or not fmtdict.get(k): continue self.log.debug("XmppDistributor is sending event as '%s' to: %s" % (fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k]) def _formats(self, transport, realm): "Find valid formats for transport and realm" formats = {} for f in self.formatters: for style in f.styles(transport, realm): formats[style] = f self.log.debug( "XmppDistributor has found the following formats capable " "of handling '%s' of '%s': %s" % (transport, realm, ', '.join(formats.keys()))) if not formats: self.log.error("XmppDistributor is unable to continue " \ "without supporting formatters.") return formats def _get_preferred_format(self, sid, realm=None): if realm: name = 'xmpp_format_%s' % realm else: name = 'xmpp_format' setting = SubscriptionSetting(self.env, name, self.xmpp_format_setting.default) return self.xmpp_format_setting.get_user_setting(sid)[0] def _do_send(self, transport, event, format, recipients, formatter): message = formatter.format(transport, event.realm, format, event) package = (recipients, message) start = time.time() if self.use_threaded_delivery: self.get_delivery_queue().put(package) else: self.send(*package) stop = time.time() self.log.debug("XmppDistributor took %s seconds to send."\ %(round(stop-start,2))) def send(self, recipients, message): """Send message to recipients via xmpp.""" jid = JID(self.user) if self.server: server = self.server else: server = jid.getDomain() cl = Client(server, port=self.port, debug=[]) if not cl.connect(): raise IOError("Couldn't connect to xmpp server %s" % server) if not cl.auth(jid.getNode(), self.password, resource=self.resource): cl.Connection.disconnect() raise IOError("Xmpp auth erro using %s to %s" % (jid, server)) default_domain = jid.getDomain() for recip in recipients: cl.send(Message(recip[2], message))
class PermissionSystem(Component): """Permission management sub-system.""" required = True implements(IPermissionRequestor) requestors = ExtensionPoint(IPermissionRequestor) group_providers = ExtensionPoint(IPermissionGroupProvider) store = ExtensionOption( 'trac', 'permission_store', IPermissionStore, 'DefaultPermissionStore', """Name of the component implementing `IPermissionStore`, which is used for managing user and group permissions.""") policies = OrderedExtensionsOption( 'trac', 'permission_policies', IPermissionPolicy, 'DefaultWikiPolicy, DefaultTicketPolicy, DefaultPermissionPolicy, ' 'LegacyAttachmentPolicy', False, """List of components implementing `IPermissionPolicy`, in the order in which they will be applied. These components manage fine-grained access control to Trac resources.""") # Number of seconds a cached user permission set is valid for. CACHE_EXPIRY = 5 # How frequently to clear the entire permission cache CACHE_REAP_TIME = 60 def __init__(self): self.permission_cache = {} self.last_reap = time_now() # Public API def grant_permission(self, username, action): """Grant the user with the given name permission to perform to specified action. :raises PermissionExistsError: if user already has the permission or is a member of the group. :since 1.3.1: raises PermissionExistsError rather than IntegrityError """ if action.isupper() and action not in self.get_actions(): raise TracError(_('%(name)s is not a valid action.', name=action)) elif not action.isupper() and action.upper() in self.get_actions(): raise TracError( _( "Permission %(name)s differs from a defined " "action by casing only, which is not allowed.", name=action)) try: self.store.grant_permission(username, action) except self.env.db_exc.IntegrityError: if action in self.get_actions(): raise PermissionExistsError( _("The user %(user)s already has permission %(action)s.", user=username, action=action)) else: raise PermissionExistsError( _("The user %(user)s is already in the group %(group)s.", user=username, group=action)) def revoke_permission(self, username, action): """Revokes the permission of the specified user to perform an action.""" self.store.revoke_permission(username, action) def get_actions_dict(self, skip=None): """Get all actions from permission requestors as a `dict`. The keys are the action names. The values are the additional actions granted by each action. For simple actions, this is an empty list. For meta actions, this is the list of actions covered by the action. :since 1.0.17: added `skip` argument. """ actions = {} for requestor in self.requestors: if requestor is skip: continue for action in requestor.get_permission_actions() or []: if isinstance(action, tuple): actions.setdefault(action[0], []).extend(action[1]) else: actions.setdefault(action, []) return actions def get_actions(self, skip=None): """Get a list of all actions defined by permission requestors.""" actions = set() for requestor in self.requestors: if requestor is skip: continue for action in requestor.get_permission_actions() or []: if isinstance(action, tuple): actions.add(action[0]) else: actions.add(action) return sorted(actions) def get_groups_dict(self): """Get all groups as a `dict`. The keys are the group names. The values are the group members. :since: 1.1.3 """ groups = sorted( (p for p in self.get_all_permissions() if not p[1].isupper()), key=lambda p: p[1]) return { k: sorted(i[0] for i in list(g)) for k, g in groupby(groups, key=lambda p: p[1]) } def get_users_dict(self): """Get all users as a `dict`. The keys are the user names. The values are the actions possessed by the user. :since: 1.1.3 """ perms = sorted( (p for p in self.get_all_permissions() if p[1].isupper()), key=lambda p: p[0]) return { k: sorted(i[1] for i in list(g)) for k, g in groupby(perms, key=lambda p: p[0]) } def get_user_permissions(self, username=None, undefined=False, expand_meta=True): """Return the permissions of the specified user. The return value is a dictionary containing all the actions granted to the user mapped to `True`. :param undefined: if `True`, include actions that are not defined in any of the `IPermissionRequestor` implementations. :param expand_meta: if `True`, expand meta permissions. :since 1.3.1: added the `undefined` parameter. :since 1.3.3: added the `expand_meta` parameter. """ if not username: # Return all permissions available in the system return dict.fromkeys(self.get_actions(), True) # Return all permissions that the given user has actions = self.get_actions_dict() user_permissions = self.store.get_user_permissions(username) or [] if expand_meta: return { p: True for p in self.expand_actions(user_permissions) if undefined or p in actions } else: return { p: True for p in user_permissions if undefined or p in actions } def get_permission_groups(self, username): """Return a sorted list of groups that `username` belongs to. Groups are recursively expanded such that if `username` is a member of `group1` and `group1` is a member of `group2`, both `group1` and `group2` will be returned. :since: 1.3.3 """ user_groups = set() for provider in self.group_providers: user_groups.update(provider.get_permission_groups(username) or []) return sorted(user_groups) def get_all_permissions(self): """Return all permissions for all users. The permissions are returned as a list of (subject, action) formatted tuples. """ return self.store.get_all_permissions() or [] def get_users_with_permission(self, permission): """Return all users that have the specified permission. Users are returned as a list of user names. """ now = time_now() if now - self.last_reap > self.CACHE_REAP_TIME: self.permission_cache = {} self.last_reap = now timestamp, permissions = self.permission_cache.get( permission, (0, None)) if now - timestamp <= self.CACHE_EXPIRY: return permissions parent_map = {} for parent, children in self.get_actions_dict().iteritems(): for child in children: parent_map.setdefault(child, set()).add(parent) satisfying_perms = set() def append_with_parents(action): if action not in satisfying_perms: satisfying_perms.add(action) for action in parent_map.get(action, ()): append_with_parents(action) append_with_parents(permission) perms = self.store.get_users_with_permissions(satisfying_perms) or [] self.permission_cache[permission] = (now, perms) return perms def expand_actions(self, actions): """Helper method for expanding all meta actions.""" all_actions = self.get_actions_dict() expanded_actions = set() def expand_action(action): if action not in expanded_actions: expanded_actions.add(action) for a in all_actions.get(action, ()): expand_action(a) for a in actions: expand_action(a) return sorted(expanded_actions) def check_permission(self, action, username=None, resource=None, perm=None): """Return True if permission to perform action for the given resource is allowed. """ if username is None: username = '******' if resource and resource.realm is None: resource = None for policy in self.policies: decision = policy.check_permission(action, username, resource, perm) if decision is not None: self.log.debug("%s %s %s performing %s on %r", policy.__class__.__name__, 'allows' if decision else 'denies', username, action, resource) return decision self.log.debug("No policy allowed %s performing %s on %r", username, action, resource) return False # IPermissionRequestor methods def get_permission_actions(self): """Implement the global `TRAC_ADMIN` meta permission. """ actions = self.get_actions(skip=self) return [('TRAC_ADMIN', actions)]
class SubscriptionManager(Component): """A class that manages data subscriptions.""" subscribables = ExtensionPoint(ISubscribable) subscribtion_filters = OrderedExtensionsOption( 'tracforge-client', 'filters', ISubscriptionFilter, include_missing=False, doc="""Filters for recieved data.""") implements(IEnvironmentSetupParticipant) # Subscription accessors def get_subscribers(self, type, db=None): """Get all envs that are subscribed to this env.""" return self._get_rows(type, 1, db) def get_subscriptions(self, type, db=None): """Get all envs this env is subscribed to.""" return self._get_rows(type, 0, db) def _get_rows(self, type, direction, db=None): db = db or self.env.get_db_cnx() cursor = db.cursor() cursor.execute( 'SELECT env FROM tracforge_subscriptions WHERE type = %s AND direction = %s', (type, str(direction))) for row in cursor: yield row[0] def get_subscribables(self): for source in self.subscribables: for x in source.subscribable_types(): yield x # Subscription mutators def subscribe_to(self, source, type): source_env = open_env(source) source_mgr = SubscriptionManager(source_env) self._change_subscription('add', source_env.path, type, 0) source_mgr._change_subscription('add', self.env.path, type, 1) def unsubscribe_from(self, source, type): source_env = open_env(source) source_mgr = SubscriptionManager(source_env) self._change_subscription('delete', source_env.path, type, 0) source_mgr._change_subscription('delete', self.env.path, type, 1) def _change_subscription(self, action, env, type, direction): db = self.env.get_db_cnx() cursor = db.cursor() if action == 'add': cursor.execute( 'INSERT INTO tracforge_subscriptions (env, type, direction) VALUES (%s,%s,%s)', (env, type, direction)) elif action == 'delete': cursor.execute( 'DELETE FROM tracforge_subscriptions WHERE env = %s AND type = %s AND direction = %s', (env, type, direction)) else: raise TracError, 'Unknown subscription operation' db.commit() # IEnvironmentSetupParticipant methods def environment_created(self): self.upgrade_environment(self.env.get_db_cnx()) def environment_needs_upgrade(self, db): cursor = db.cursor() cursor.execute( "SELECT value FROM system WHERE name = 'tracforge_subscriptions'") value = cursor.fetchone() if not value: self.found_db_version = None return True else: self.found_db_version = int(value[0]) self.log.debug( 'SubscriptionManager: Found db version %s, current is %s' % (self.found_db_version, db_version)) return self.found_db_version < db_version def upgrade_environment(self, db): # 0.10 compatibility hack (thanks Alec) try: from trac.db import DatabaseManager db_manager, _ = DatabaseManager(self.env)._get_connector() except ImportError: db_manager = db # Insert the default table cursor = db.cursor() if self.found_db_version == None: cursor.execute( "INSERT INTO system (name, value) VALUES ('tracforge_subscriptions', %s)", (db_version, )) else: cursor.execute( "UPDATE system SET value = %s WHERE name = 'tracforge_subscriptions'", (db_version, )) cursor.execute('DROP TABLE tracforge_subscriptions') for sql in db_manager.to_sql(default_table): cursor.execute(sql) db.commit()
class PermissionSystem(Component): """Permission management sub-system.""" required = True implements(IPermissionRequestor) requestors = ExtensionPoint(IPermissionRequestor) store = ExtensionOption( 'trac', 'permission_store', IPermissionStore, 'DefaultPermissionStore', """Name of the component implementing `IPermissionStore`, which is used for managing user and group permissions.""") policies = OrderedExtensionsOption( 'trac', 'permission_policies', IPermissionPolicy, 'ReadonlyWikiPolicy, DefaultPermissionPolicy, LegacyAttachmentPolicy', False, """List of components implementing `IPermissionPolicy`, in the order in which they will be applied. These components manage fine-grained access control to Trac resources.""") # Number of seconds a cached user permission set is valid for. CACHE_EXPIRY = 5 # How frequently to clear the entire permission cache CACHE_REAP_TIME = 60 def __init__(self): self.permission_cache = {} self.last_reap = time() # Public API def grant_permission(self, username, action): """Grant the user with the given name permission to perform to specified action.""" if action.isupper() and action not in self.get_actions(): raise TracError(_('%(name)s is not a valid action.', name=action)) elif not action.isupper() and action.upper() in self.get_actions(): raise TracError( _( "Permission %(name)s differs from a defined " "action by casing only, which is not allowed.", name=action)) self.store.grant_permission(username, action) def revoke_permission(self, username, action): """Revokes the permission of the specified user to perform an action.""" self.store.revoke_permission(username, action) def get_actions_dict(self): """Get all actions from permission requestors as a `dict`. The keys are the action names. The values are the additional actions granted by each action. For simple actions, this is an empty list. For meta actions, this is the list of actions covered by the action. """ actions = {} for requestor in self.requestors: for action in requestor.get_permission_actions() or []: if isinstance(action, tuple): actions.setdefault(action[0], []).extend(action[1]) else: actions.setdefault(action, []) return actions def get_actions(self, skip=None): """Get a list of all actions defined by permission requestors.""" actions = set() for requestor in self.requestors: if requestor is skip: continue for action in requestor.get_permission_actions() or []: if isinstance(action, tuple): actions.add(action[0]) else: actions.add(action) return list(actions) def get_groups_dict(self): """Get all groups as a `dict`. The keys are the group names. The values are the group members. :since: 1.1.3 """ groups = sorted( (p for p in self.get_all_permissions() if not p[1].isupper()), key=lambda p: p[1]) return dict((k, sorted(i[0] for i in list(g))) for k, g in groupby(groups, key=lambda p: p[1])) def get_users_dict(self): """Get all users as a `dict`. The keys are the user names. The values are the actions possessed by the user. :since: 1.1.3 """ perms = sorted( (p for p in self.get_all_permissions() if p[1].isupper()), key=lambda p: p[0]) return dict((k, sorted(i[1] for i in list(g))) for k, g in groupby(perms, key=lambda p: p[0])) def get_user_permissions(self, username=None): """Return the permissions of the specified user. The return value is a dictionary containing all the actions granted to the user mapped to `True`. If an action is missing as a key, or has `False` as a value, permission is denied.""" if not username: # Return all permissions available in the system return dict.fromkeys(self.get_actions(), True) # Return all permissions that the given user has actions = self.get_actions_dict() permissions = {} def expand_meta(action): if action not in permissions: permissions[action] = True for a in actions.get(action, ()): expand_meta(a) for perm in self.store.get_user_permissions(username) or []: expand_meta(perm) return permissions def get_all_permissions(self): """Return all permissions for all users. The permissions are returned as a list of (subject, action) formatted tuples.""" return self.store.get_all_permissions() or [] def get_users_with_permission(self, permission): """Return all users that have the specified permission. Users are returned as a list of user names. """ now = time() if now - self.last_reap > self.CACHE_REAP_TIME: self.permission_cache = {} self.last_reap = now timestamp, permissions = self.permission_cache.get( permission, (0, None)) if now - timestamp <= self.CACHE_EXPIRY: return permissions parent_map = {} for parent, children in self.get_actions_dict().iteritems(): for child in children: parent_map.setdefault(child, set()).add(parent) satisfying_perms = set() def append_with_parents(action): if action not in satisfying_perms: satisfying_perms.add(action) for action in parent_map.get(action, ()): append_with_parents(action) append_with_parents(permission) perms = self.store.get_users_with_permissions(satisfying_perms) or [] self.permission_cache[permission] = (now, perms) return perms def expand_actions(self, actions): """Helper method for expanding all meta actions.""" all_actions = self.get_actions_dict() expanded_actions = set() def expand_action(action): if action not in expanded_actions: expanded_actions.add(action) for a in all_actions.get(action, ()): expand_action(a) for a in actions: expand_action(a) return expanded_actions def check_permission(self, action, username=None, resource=None, perm=None): """Return True if permission to perform action for the given resource is allowed.""" if username is None: username = '******' if resource and resource.realm is None: resource = None for policy in self.policies: decision = policy.check_permission(action, username, resource, perm) if decision is not None: if decision is False: self.log.debug("%s denies %s performing %s on %r", policy.__class__.__name__, username, action, resource) return decision self.log.debug("No policy allowed %s performing %s on %r", username, action, resource) return False # IPermissionRequestor methods def get_permission_actions(self): """Implement the global `TRAC_ADMIN` meta permission. """ actions = self.get_actions(skip=self) return [('TRAC_ADMIN', actions)]
class MilestoneSystem(Component): """ Copy of the TicketSystem for enabling the same functionality for milestones. """ action_controllers = OrderedExtensionsOption( 'itteco-milestone', 'workflow', IMilestoneActionController, default='ConfigurableMilestoneWorkflow', include_missing=False, doc= """Ordered list of workflow controllers to use for milestone actions. Reserved for future use.""" ) tickets_report = IntOption( 'itteco-milestone', 'tickets_report', doc= """The number of the report that is to be rendered in in milestone editor.""" ) starting_action = ListOption( 'itteco-milestone', 'starting_action', 'start,reassign', doc="""List of the actions that mark milestone as started.""") completing_action = ListOption( 'itteco-milestone', 'completing_action', 'finish,resolve', doc="""List of the actions that mark milestone as started.""") _fields = None _custom_fields = None def __init__(self): self.log.debug('action controllers for milestone workflow: %r' % [c.__class__.__name__ for c in self.action_controllers]) self._fields_lock = threading.RLock() # Public API def get_available_actions(self, req, milestone): """Returns a sorted list of available actions""" # The list should not have duplicates. actions = {} for controller in self.action_controllers: weighted_actions = controller.get_milestone_actions(req, milestone) for weight, action in weighted_actions: if action in actions: actions[action] = max(actions[action], weight) else: actions[action] = weight all_weighted_actions = [(weight, action) for action, weight in actions.items()] return [x[1] for x in sorted(all_weighted_actions, reverse=True)] def get_all_status(self): """Returns a sorted list of all the states all of the action controllers know about.""" valid_states = set() for controller in self.action_controllers: valid_states.update(controller.get_all_status()) return sorted(valid_states) def get_milestone_fields(self): """Returns the list of fields available for milestones.""" # This is now cached - as it makes quite a number of things faster, # e.g. #6436 if self._fields is None: self._fields_lock.acquire() try: if self._fields is None: # double-check (race after 1st check) self._fields = self._get_milestone_fields() finally: self._fields_lock.release() return [f.copy() for f in self._fields] def reset_milestone_fields(self): self._fields_lock.acquire() try: self._fields = None self.config.touch() # brute force approach for now finally: self._fields_lock.release() def _get_milestone_fields(self): db = self.env.get_db_cnx() fields = [{ 'name': 'summary', 'type': 'text', 'label': 'Summary' }, { 'name': 'description', 'type': 'textarea', 'label': 'Description' }, { 'name': 'duedate', 'type': 'text', 'label': 'Due date', 'skip': True, 'custom': True }, { 'name': 'completedate', 'type': 'text', 'label': 'Complete date', 'skip': True, 'custom': True }, { 'name': 'started', 'type': 'text', 'label': 'Started At', 'skip': True, 'custom': True }, { 'name': 'type', 'type': 'text', 'label': 'TypesAt', 'skip': True }, { 'name': 'milestone', 'type': 'text', 'label': 'Parent', 'options': [] }, { 'name': 'owner', 'type': 'text', 'label': 'Owner' }, { 'name': 'reporter', 'type': 'text', 'label': 'Reporter', 'skip': True }, { 'name': 'status', 'type': 'select', 'label': 'Status', 'options': MilestoneSystem(self.env).get_all_status(), 'hidden': True }] #put the default fields is any for field in self.get_custom_fields(): if field['name'] in [f['name'] for f in fields]: self.log.warning('Duplicate field name "%s" (ignoring)', field['name']) continue if field['name'] in self.reserved_field_names: self.log.warning( 'Field name "%s" is a reserved name ' '(ignoring)', field['name']) continue if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): self.log.warning( 'Invalid name for custom field: "%s" ' '(ignoring)', field['name']) continue fields.append(field) return fields reserved_field_names = [ 'report', 'order', 'desc', 'group', 'groupdesc', 'col', 'row', 'format', 'max', 'page', 'verbose', 'comment' ] def get_custom_fields(self): if self._custom_fields is None: self._fields_lock.acquire() try: if self._custom_fields is None: # double-check self._custom_fields = self._get_custom_fields() finally: self._fields_lock.release() return [f.copy() for f in self._custom_fields] def _get_custom_fields(self): fields = [] config = self.config['milestone-custom'] for name in [ option for option, value in config.options() if '.' not in option ]: field = { 'name': name, 'type': config.get(name), 'custom': True, 'order': config.getint(name + '.order', 0), 'label': config.get(name + '.label') or name.capitalize(), 'value': config.get(name + '.value', '') } if field['type'] == 'select' or field['type'] == 'radio': field['options'] = config.getlist(name + '.options', sep='|') if '' in field['options']: field['optional'] = True field['options'].remove('') elif field['type'] == 'text': field['format'] = config.get(name + '.format', 'plain') elif field['type'] == 'textarea': field['format'] = config.get(name + '.format', 'plain') field['width'] = config.getint(name + '.cols') field['height'] = config.getint(name + '.rows') fields.append(field) fields.sort(lambda x, y: cmp(x['order'], y['order'])) return fields
class RequestDispatcher(Component): """Web request dispatcher. This component dispatches incoming requests to registered handlers. Besides, it also takes care of user authentication and request pre- and post-processing. """ required = True implements(ITemplateProvider) authenticators = ExtensionPoint(IAuthenticator) handlers = ExtensionPoint(IRequestHandler) filters = OrderedExtensionsOption( 'trac', 'request_filters', IRequestFilter, doc="""Ordered list of filters to apply to all requests.""") default_handler = ExtensionOption( 'trac', 'default_handler', IRequestHandler, 'WikiModule', """Name of the component that handles requests to the base URL. Options include `TimelineModule`, `RoadmapModule`, `BrowserModule`, `QueryModule`, `ReportModule`, `TicketModule` and `WikiModule`.""") default_timezone = Option('trac', 'default_timezone', '', """The default timezone to use""") default_language = Option( 'trac', 'default_language', '', """The preferred language to use if no user preference has been set. (''since 0.12.1'') """) default_date_format = ChoiceOption( 'trac', 'default_date_format', ('', 'iso8601'), """The date format. Valid options are 'iso8601' for selecting ISO 8601 format, or leave it empty which means the default date format will be inferred from the browser's default language. (''since 1.0'') """) use_xsendfile = BoolOption( 'trac', 'use_xsendfile', 'false', """When true, send a `X-Sendfile` header and no content when sending files from the filesystem, so that the web server handles the content. This requires a web server that knows how to handle such a header, like Apache with `mod_xsendfile` or lighttpd. (''since 1.0'') """) xsendfile_header = Option( 'trac', 'xsendfile_header', 'X-Sendfile', """The header to use if `use_xsendfile` is enabled. If Nginx is used, set `X-Accel-Redirect`. (''since 1.0.6'')""") # Public API def authenticate(self, req): for authenticator in self.authenticators: try: authname = authenticator.authenticate(req) except TracError as e: self.log.error("Can't authenticate using %s: %s", authenticator.__class__.__name__, exception_to_unicode(e, traceback=True)) add_warning( req, _("Authentication error. " "Please contact your administrator.")) break # don't fallback to other authenticators if authname: return authname return 'anonymous' def dispatch(self, req): """Find a registered handler that matches the request and let it process it. In addition, this method initializes the data dictionary passed to the the template and adds the web site chrome. """ self.log.debug('Dispatching %r', req) chrome = Chrome(self.env) # Setup request callbacks for lazily-evaluated properties req.callbacks.update({ 'authname': self.authenticate, 'chrome': chrome.prepare_request, 'perm': self._get_perm, 'session': self._get_session, 'locale': self._get_locale, 'lc_time': self._get_lc_time, 'tz': self._get_timezone, 'form_token': self._get_form_token, 'use_xsendfile': self._get_use_xsendfile, 'xsendfile_header': self._get_xsendfile_header, }) try: try: # Select the component that should handle the request chosen_handler = None try: for handler in self._request_handlers.values(): if handler.match_request(req): chosen_handler = handler break if not chosen_handler and \ (not req.path_info or req.path_info == '/'): chosen_handler = self._get_valid_default_handler(req) # pre-process any incoming request, whether a handler # was found or not self.log.debug("Chosen handler is %s", chosen_handler) chosen_handler = \ self._pre_process_request(req, chosen_handler) except TracError as e: raise HTTPInternalError(e) if not chosen_handler: if req.path_info.endswith('/'): # Strip trailing / and redirect target = unicode_quote(req.path_info.rstrip('/')) if req.query_string: target += '?' + req.query_string req.redirect(req.href + target, permanent=True) raise HTTPNotFound('No handler matched request to %s', req.path_info) req.callbacks['chrome'] = partial(chrome.prepare_request, handler=chosen_handler) # Protect against CSRF attacks: we validate the form token # for all POST requests with a content-type corresponding # to form submissions if req.method == 'POST': ctype = req.get_header('Content-Type') if ctype: ctype, options = cgi.parse_header(ctype) if ctype in ('application/x-www-form-urlencoded', 'multipart/form-data') and \ req.args.get('__FORM_TOKEN') != req.form_token: if self.env.secure_cookies and req.scheme == 'http': msg = _('Secure cookies are enabled, you must ' 'use https to submit forms.') else: msg = _('Do you have cookies enabled?') raise HTTPBadRequest( _('Missing or invalid form token.' ' %(msg)s', msg=msg)) # Process the request and render the template resp = chosen_handler.process_request(req) if resp: if len(resp) == 2: # old Clearsilver template and HDF data self.log.error( "Clearsilver template are no longer " "supported (%s)", resp[0]) raise TracError( _("Clearsilver templates are no longer supported, " "please contact your Trac administrator.")) # Genshi template, data, content_type, method = \ self._post_process_request(req, *resp) if 'hdfdump' in req.args: req.perm.require('TRAC_ADMIN') # debugging helper - no need to render first out = io.BytesIO() pprint(data, out) req.send(out.getvalue(), 'text/plain') self.log.debug("Rendering response from handler") output = chrome.render_template( req, template, data, content_type, method=method, iterable=chrome.use_chunked_encoding) req.send(output, content_type or 'text/html') else: self.log.debug("Empty or no response from handler. " "Entering post_process_request.") self._post_process_request(req) except RequestDone: raise except: # post-process the request in case of errors err = sys.exc_info() try: self._post_process_request(req) except RequestDone: raise except Exception as e: self.log.error( "Exception caught while post-processing" " request: %s", exception_to_unicode(e, traceback=True)) raise err[0], err[1], err[2] except PermissionError as e: raise HTTPForbidden(e) except ResourceNotFound as e: raise HTTPNotFound(e) except TracError as e: raise HTTPInternalError(e) # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac.web', 'templates')] # Internal methods @lazy def _request_handlers(self): return dict( (handler.__class__.__name__, handler) for handler in self.handlers) def _get_valid_default_handler(self, req): # Use default_handler from the Session if it is a valid value. name = req.session.get('default_handler') handler = self._request_handlers.get(name) if handler and not is_valid_default_handler(handler): handler = None if not handler: # Use default_handler from project configuration. handler = self.default_handler if not is_valid_default_handler(handler): raise ConfigurationError( tag_( "%(handler)s is not a valid default handler. Please " "update %(option)s through the %(page)s page or by " "directly editing trac.ini.", handler=tag.code(handler.__class__.__name__), option=tag.code("[trac] default_handler"), page=tag.a(_("Basic Settings"), href=req.href.admin('general/basics')))) return handler def _get_perm(self, req): if isinstance(req.session, FakeSession): return FakePerm() else: return PermissionCache(self.env, req.authname) def _get_session(self, req): try: return Session(self.env, req) except TracError as e: self.log.error("can't retrieve session: %s", exception_to_unicode(e)) return FakeSession() def _get_locale(self, req): if has_babel: preferred = req.session.get('language') default = self.env.config.get('trac', 'default_language', '') negotiated = get_negotiated_locale([preferred, default] + req.languages) self.log.debug("Negotiated locale: %s -> %s", preferred, negotiated) return negotiated def _get_lc_time(self, req): lc_time = req.session.get('lc_time') if not lc_time or lc_time == 'locale' and not has_babel: lc_time = self.default_date_format if lc_time == 'iso8601': return 'iso8601' return req.locale def _get_timezone(self, req): try: return timezone( req.session.get('tz', self.default_timezone or 'missing')) except Exception: return localtz def _get_form_token(self, req): """Used to protect against CSRF. The 'form_token' is strong shared secret stored in a user cookie. By requiring that every POST form to contain this value we're able to protect against CSRF attacks. Since this value is only known by the user and not by an attacker. If the the user does not have a `trac_form_token` cookie a new one is generated. """ if 'trac_form_token' in req.incookie: return req.incookie['trac_form_token'].value else: req.outcookie['trac_form_token'] = hex_entropy(24) req.outcookie['trac_form_token']['path'] = req.base_path or '/' if self.env.secure_cookies: req.outcookie['trac_form_token']['secure'] = True req.outcookie['trac_form_token']['httponly'] = True return req.outcookie['trac_form_token'].value def _get_use_xsendfile(self, req): return self.use_xsendfile # RFC7230 3.2 Header Fields _xsendfile_header_re = re.compile(r"[-0-9A-Za-z!#$%&'*+.^_`|~]+\Z") _warn_xsendfile_header = False def _get_xsendfile_header(self, req): header = self.xsendfile_header.strip() if self._xsendfile_header_re.match(header): return to_utf8(header) else: if not self._warn_xsendfile_header: self._warn_xsendfile_header = True self.log.warn("[trac] xsendfile_header is invalid: '%s'", header) return None def _pre_process_request(self, req, chosen_handler): for filter_ in self.filters: chosen_handler = filter_.pre_process_request(req, chosen_handler) return chosen_handler def _post_process_request(self, req, *args): resp = args # `method` is optional in IRequestHandler's response. If not # specified, the default value is appended to response. if len(resp) == 3: resp += (None, ) nbargs = len(resp) for f in reversed(self.filters): # As the arity of `post_process_request` has changed since # Trac 0.10, only filters with same arity gets passed real values. # Errors will call all filters with None arguments, # and results will not be not saved. extra_arg_count = arity(f.post_process_request) - 1 if extra_arg_count == nbargs: resp = f.post_process_request(req, *resp) elif extra_arg_count == nbargs - 1: # IRequestFilters may modify the `method`, but the `method` # is forwarded when not accepted by the IRequestFilter. method = resp[-1] resp = f.post_process_request(req, *resp[:-1]) resp += (method, ) elif nbargs == 0: f.post_process_request(req, *(None, ) * extra_arg_count) return resp
class EmailDistributor(Component): implements(IAnnouncementDistributor, IAnnouncementPreferenceProvider) formatters = ExtensionPoint(IAnnouncementFormatter) producers = ExtensionPoint(IAnnouncementProducer) distributors = ExtensionPoint(IAnnouncementDistributor) # Make ordered decorators = ExtensionPoint(IAnnouncementEmailDecorator) resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\ 'SessionEmailResolver, DefaultDomainEmailResolver', """Comma seperated list of email resolver components in the order they will be called. If an email address is resolved, the remaining resolvers will not be called. """) email_sender = ExtensionOption( 'announcer', 'email_sender', IEmailSender, 'SmtpEmailSender', """Name of the component implementing `IEmailSender`. This component is used by the announcer system to send emails. Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided. """) enabled = BoolOption('announcer', 'email_enabled', 'true', """Enable email notification.""") email_from = Option('announcer', 'email_from', 'trac@localhost', """Sender address to use in notification emails.""") from_name = Option('announcer', 'email_from_name', '', """Sender name to use in notification emails.""") replyto = Option('announcer', 'email_replyto', 'trac@localhost', """Reply-To address to use in notification emails.""") mime_encoding = ChoiceOption( 'announcer', 'mime_encoding', ['base64', 'qp', 'none'], """Specifies the MIME encoding scheme for emails. Valid options are 'base64' for Base64 encoding, 'qp' for Quoted-Printable, and 'none' for no encoding. Note that the no encoding means that non-ASCII characters in text are going to cause problems with notifications. """) use_public_cc = BoolOption( 'announcer', 'use_public_cc', 'false', """Recipients can see email addresses of other CC'ed recipients. If this option is disabled (the default), recipients are put on BCC """) # used in email decorators, but not here subject_prefix = Option( 'announcer', 'email_subject_prefix', '__default__', """Text to prepend to subject line of notification emails. If the setting is not defined, then the [$project_name] prefix. If no prefix is desired, then specifying an empty option will disable it. """) to = Option('announcer', 'email_to', 'undisclosed-recipients: ;', 'Default To: field') use_threaded_delivery = BoolOption( 'announcer', 'use_threaded_delivery', 'false', """Do message delivery in a separate thread. Enabling this will improve responsiveness for requests that end up with an announcement being sent over email. It requires building Python with threading support enabled-- which is usually the case. To test, start Python and type 'import threading' to see if it raises an error. """) default_email_format = Option( 'announcer', 'default_email_format', 'text/plain', """The default mime type of the email notifications. This can be overridden on a per user basis through the announcer preferences panel. """) set_message_id = BoolOption( 'announcer', 'set_message_id', 'true', """Disable if you would prefer to let the email server handle message-id generation. """) rcpt_allow_regexp = Option( 'announcer', 'rcpt_allow_regexp', '', """A whitelist pattern to match any address to before adding to recipients list. """) rcpt_local_regexp = Option( 'announcer', 'rcpt_local_regexp', '', """A whitelist pattern to match any address, that should be considered local. This will be evaluated only if msg encryption is set too. Recipients with matching email addresses will continue to receive unencrypted email messages. """) crypto = Option( 'announcer', 'email_crypto', '', """Enable cryptographically operation on email msg body. Empty string, the default for unset, disables all crypto operations. Valid values are: sign sign msg body with given privkey encrypt encrypt msg body with pubkeys of all recipients sign,encrypt sign, than encrypt msg body """) # get GnuPG configuration options gpg_binary = Option( 'announcer', 'gpg_binary', 'gpg', """GnuPG binary name, allows for full path too. Value 'gpg' is same default as in python-gnupg itself. For usual installations location of the gpg binary is auto-detected. """) gpg_home = Option( 'announcer', 'gpg_home', '', """Directory containing keyring files. In case of wrong configuration missing keyring files without content will be created in the configured location, provided necessary write permssion is granted for the corresponding parent directory. """) private_key = Option( 'announcer', 'gpg_signing_key', None, """Keyid of private key (last 8 chars or more) used for signing. If unset, a private key will be selected from keyring automagicly. The password must be available i.e. provided by running gpg-agent or empty (bad security). On failing to unlock the private key, msg body will get emptied. """) def __init__(self): self.delivery_queue = None self._init_pref_encoding() def get_delivery_queue(self): if not self.delivery_queue: self.delivery_queue = Queue.Queue() thread = DeliveryThread(self.delivery_queue, self.send) thread.start() return self.delivery_queue # IAnnouncementDistributor def transports(self): yield "email" def formats(self, transport, realm): "Find valid formats for transport and realm" formats = {} for f in self.formatters: for style in f.styles(transport, realm): formats[style] = f self.log.debug( "EmailDistributor has found the following formats capable " "of handling '%s' of '%s': %s" % (transport, realm, ', '.join(formats.keys()))) if not formats: self.log.error("EmailDistributor is unable to continue " \ "without supporting formatters.") return formats def distribute(self, transport, recipients, event): found = False for supported_transport in self.transports(): if supported_transport == transport: found = True if not self.enabled or not found: self.log.debug("EmailDistributer email_enabled set to false") return fmtdict = self.formats(transport, event.realm) if not fmtdict: self.log.error("EmailDistributer No formats found for %s %s" % (transport, event.realm)) return msgdict = {} msgdict_encrypt = {} msg_pubkey_ids = [] # compile pattern before use for better performance RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp) RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp) if self.crypto != '': self.log.debug("EmailDistributor attempts crypto operation.") self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home) for name, authed, addr in recipients: fmt = name and \ self._get_preferred_format(event.realm, name, authed) or \ self._get_default_format() if fmt not in fmtdict: self.log.debug(("EmailDistributer format %s not available " + "for %s %s, looking for an alternative") % (fmt, transport, event.realm)) # If the fmt is not available for this realm, then try to find # an alternative oldfmt = fmt fmt = None for f in fmtdict.values(): fmt = f.alternative_style_for(transport, event.realm, oldfmt) if fmt: break if not fmt: self.log.error( "EmailDistributer was unable to find a formatter " + "for format %s" % k) continue rslvr = None if name and not addr: # figure out what the addr should be if it's not defined for rslvr in self.resolvers: addr = rslvr.get_address_for_name(name, authed) if addr: break if addr: self.log.debug("EmailDistributor found the " \ "address '%s' for '%s (%s)' via: %s"%( addr, name, authed and \ 'authenticated' or 'not authenticated', rslvr.__class__.__name__)) # ok, we found an addr, add the message # but wait, check for allowed rcpt first, if set if RCPT_ALLOW_RE.search(addr) is not None: # check for local recipients now local_match = RCPT_LOCAL_RE.search(addr) if self.crypto in ['encrypt', 'sign,encrypt'] and \ local_match is None: # search available public keys for matching UID pubkey_ids = self.enigma.get_pubkey_ids(addr) if len(pubkey_ids) > 0: msgdict_encrypt.setdefault(fmt, set()).add( (name, authed, addr)) msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids self.log.debug("EmailDistributor got pubkeys " \ "for %s: %s" % (addr, pubkey_ids)) else: self.log.debug("EmailDistributor dropped %s " \ "after missing pubkey with corresponding " \ "address %s in any UID" % (name, addr)) else: msgdict.setdefault(fmt, set()).add( (name, authed, addr)) if local_match is not None: self.log.debug("EmailDistributor expected " \ "local delivery for %s to: %s" % (name, addr)) else: self.log.debug("EmailDistributor dropped %s for " \ "not matching allowed recipient pattern %s" % \ (addr, self.rcpt_allow_regexp)) else: self.log.debug("EmailDistributor was unable to find an " \ "address for: %s (%s)"%(name, authed and \ 'authenticated' or 'not authenticated')) for k, v in msgdict.items(): if not v or not fmtdict.get(k): continue self.log.debug("EmailDistributor is sending event as '%s' to: %s" % (fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k]) for k, v in msgdict_encrypt.items(): if not v or not fmtdict.get(k): continue self.log.debug( "EmailDistributor is sending encrypted info on event " \ "as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids) def _get_default_format(self): return self.default_email_format def _get_preferred_format(self, realm, sid, authenticated): if authenticated is None: authenticated = 0 db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ SELECT value FROM session_attribute WHERE sid=%s AND authenticated=%s AND name=%s """, (sid, int(authenticated), 'announcer_email_format_%s' % realm)) result = cursor.fetchone() if result: chosen = result[0] self.log.debug("EmailDistributor determined the preferred format" \ " for '%s (%s)' is: %s"%(sid, authenticated and \ 'authenticated' or 'not authenticated', chosen)) return chosen else: return self._get_default_format() def _init_pref_encoding(self): self._charset = Charset() self._charset.input_charset = 'utf-8' pref = self.mime_encoding.lower() if pref == 'base64': self._charset.header_encoding = BASE64 self._charset.body_encoding = BASE64 self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref in ['qp', 'quoted-printable']: self._charset.header_encoding = QP self._charset.body_encoding = QP self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref == 'none': self._charset.header_encoding = None self._charset.body_encoding = None self._charset.input_codec = None self._charset.output_charset = 'ascii' else: raise TracError(_('Invalid email encoding setting: %s' % pref)) def _message_id(self, realm): """Generate an unique message ID.""" modtime = time.time() s = '%s.%d.%s' % (self.env.project_url, modtime, realm.encode('ascii', 'ignore')) dig = md5(s).hexdigest() host = self.email_from[self.email_from.find('@') + 1:] msgid = '<%03d.%s@%s>' % (len(s), dig, host) return msgid def _filter_recipients(self, rcpt): return rcpt def _do_send(self, transport, event, format, recipients, formatter, pubkey_ids=[]): output = formatter.format(transport, event.realm, format, event) # DEVEL: force message body plaintext style for crypto operations if self.crypto != '' and pubkey_ids != []: if self.crypto == 'sign': output = self.enigma.sign(output, self.private_key) elif self.crypto == 'encrypt': output = self.enigma.encrypt(output, pubkey_ids) elif self.crypto == 'sign,encrypt': output = self.enigma.sign_encrypt(output, pubkey_ids, self.private_key) self.log.debug(output) self.log.debug(_("EmailDistributor crypto operaton successful.")) alternate_output = None else: alternate_style = formatter.alternative_style_for( transport, event.realm, format) if alternate_style: alternate_output = formatter.format(transport, event.realm, alternate_style, event) else: alternate_output = None # sanity check if not self._charset.body_encoding: try: dummy = output.encode('ascii') except UnicodeDecodeError: raise TracError(_("Ticket contains non-ASCII chars. " \ "Please change encoding setting")) rootMessage = MIMEMultipart("related") headers = dict() if self.set_message_id: # A different, predictable, but still sufficiently unique # message ID will be generated as replacement in # announcer.email_decorators.generic.ThreadingEmailDecorator # for email threads to work. headers['Message-ID'] = self._message_id(event.realm) headers['Date'] = formatdate() from_header = formataddr((self.from_name or self.env.project_name, self.email_from)) headers['From'] = from_header headers['To'] = '"%s"' % (self.to) if self.use_public_cc: headers['Cc'] = ', '.join([x[2] for x in recipients if x]) headers['Reply-To'] = self.replyto for k, v in headers.iteritems(): set_header(rootMessage, k, v) rootMessage.preamble = 'This is a multi-part message in MIME format.' if alternate_output: parentMessage = MIMEMultipart('alternative') rootMessage.attach(parentMessage) alt_msg_format = 'html' in alternate_style and 'html' or 'plain' msgText = MIMEText(alternate_output, alt_msg_format) parentMessage.attach(msgText) else: parentMessage = rootMessage msg_format = 'html' in format and 'html' or 'plain' msgText = MIMEText(output, msg_format) del msgText['Content-Transfer-Encoding'] msgText.set_charset(self._charset) parentMessage.attach(msgText) decorators = self._get_decorators() if len(decorators) > 0: decorator = decorators.pop() decorator.decorate_message(event, rootMessage, decorators) recip_adds = [x[2] for x in recipients if x] # Append any to, cc or bccs added to the recipient list for field in ('To', 'Cc', 'Bcc'): if rootMessage[field] and \ len(str(rootMessage[field]).split(',')) > 0: for addy in str(rootMessage[field]).split(','): self._add_recipient(recip_adds, addy) # replace with localized bcc hint if headers['To'] == 'undisclosed-recipients: ;': set_header(rootMessage, 'To', _('undisclosed-recipients: ;')) self.log.debug("Content of recip_adds: %s" % (recip_adds)) package = (from_header, recip_adds, rootMessage.as_string()) start = time.time() if self.use_threaded_delivery: self.get_delivery_queue().put(package) else: self.send(*package) stop = time.time() self.log.debug("EmailDistributor took %s seconds to send."\ %(round(stop-start,2))) def send(self, from_addr, recipients, message): """Send message to recipients via e-mail.""" # Ensure the message complies with RFC2822: use CRLF line endings message = CRLF.join(re.split("\r?\n", message)) self.email_sender.send(from_addr, recipients, message) def _get_decorators(self): return self.decorators[:] def _add_recipient(self, recipients, addy): if addy.strip() != '"undisclosed-recipients: ;"': recipients.append(addy) # IAnnouncementDistributor def get_announcement_preference_boxes(self, req): yield "email", _("E-Mail Format") def render_announcement_preference_box(self, req, panel): supported_realms = {} for producer in self.producers: for realm in producer.realms(): for distributor in self.distributors: for transport in distributor.transports(): for fmtr in self.formatters: for style in fmtr.styles(transport, realm): if realm not in supported_realms: supported_realms[realm] = set() supported_realms[realm].add(style) if req.method == "POST": for realm in supported_realms: opt = req.args.get('email_format_%s' % realm, False) if opt: req.session['announcer_email_format_%s' % realm] = opt prefs = {} for realm in supported_realms: prefs[realm] = req.session.get('announcer_email_format_%s' % realm, None) or self._get_default_format() data = dict( realms=supported_realms, preferences=prefs, ) return "prefs_announcer_email.html", data
class PermissionSystem(Component): """Permission management sub-system.""" required = True implements(IPermissionRequestor) requestors = ExtensionPoint(IPermissionRequestor) store = ExtensionOption( 'trac', 'permission_store', IPermissionStore, 'DefaultPermissionStore', """Name of the component implementing `IPermissionStore`, which is used for managing user and group permissions.""") policies = OrderedExtensionsOption( 'trac', 'permission_policies', IPermissionPolicy, 'DefaultPermissionPolicy, LegacyAttachmentPolicy', False, """List of components implementing `IPermissionPolicy`, in the order in which they will be applied. These components manage fine-grained access control to Trac resources. Defaults to the DefaultPermissionPolicy (pre-0.11 behavior) and LegacyAttachmentPolicy (map ATTACHMENT_* permissions to realm specific ones)""") # Number of seconds a cached user permission set is valid for. CACHE_EXPIRY = 5 # How frequently to clear the entire permission cache CACHE_REAP_TIME = 60 def __init__(self): self.permission_cache = {} self.last_reap = time_now() # Public API def grant_permission(self, username, action): """Grant the user with the given name permission to perform to specified action.""" if action.isupper() and action not in self.get_actions(): raise TracError(_('%(name)s is not a valid action.', name=action)) self.store.grant_permission(username, action) def revoke_permission(self, username, action): """Revokes the permission of the specified user to perform an action.""" self.store.revoke_permission(username, action) def get_actions_dict(self): """Get all actions from permission requestors as a `dict`. The keys are the action names. The values are the additional actions granted by each action. For simple actions, this is an empty list. For meta actions, this is the list of actions covered by the action. """ actions = {} for requestor in self.requestors: for action in requestor.get_permission_actions() or []: if isinstance(action, tuple): actions.setdefault(action[0], []).extend(action[1]) else: actions.setdefault(action, []) return actions def get_actions(self, skip=None): """Get a list of all actions defined by permission requestors.""" actions = set() for requestor in self.requestors: if requestor is skip: continue for action in requestor.get_permission_actions() or []: if isinstance(action, tuple): actions.add(action[0]) else: actions.add(action) return list(actions) def get_user_permissions(self, username=None): """Return the permissions of the specified user. The return value is a dictionary containing all the actions granted to the user mapped to `True`. If an action is missing as a key, or has `False` as a value, permission is denied.""" if not username: # Return all permissions available in the system return dict.fromkeys(self.get_actions(), True) # Return all permissions that the given user has actions = self.get_actions_dict() permissions = {} def expand_meta(action): if action not in permissions: permissions[action] = True for a in actions.get(action, ()): expand_meta(a) for perm in self.store.get_user_permissions(username) or []: expand_meta(perm) return permissions def get_all_permissions(self): """Return all permissions for all users. The permissions are returned as a list of (subject, action) formatted tuples.""" return self.store.get_all_permissions() or [] def get_users_with_permission(self, permission): """Return all users that have the specified permission. Users are returned as a list of user names. """ now = time_now() if now - self.last_reap > self.CACHE_REAP_TIME: self.permission_cache = {} self.last_reap = now timestamp, permissions = self.permission_cache.get( permission, (0, None)) if now - timestamp <= self.CACHE_EXPIRY: return permissions parent_map = {} for parent, children in self.get_actions_dict().iteritems(): for child in children: parent_map.setdefault(child, set()).add(parent) satisfying_perms = set() def append_with_parents(action): if action not in satisfying_perms: satisfying_perms.add(action) for action in parent_map.get(action, ()): append_with_parents(action) append_with_parents(permission) perms = self.store.get_users_with_permissions(satisfying_perms) or [] self.permission_cache[permission] = (now, perms) return perms def expand_actions(self, actions): """Helper method for expanding all meta actions.""" all_actions = self.get_actions_dict() expanded_actions = set() def expand_action(action): if action not in expanded_actions: expanded_actions.add(action) for a in all_actions.get(action, ()): expand_action(a) for a in actions: expand_action(a) return expanded_actions def check_permission(self, action, username=None, resource=None, perm=None): """Return True if permission to perform action for the given resource is allowed.""" if username is None: username = '******' if resource: if resource.realm is None: resource = None elif resource.neighborhood is not None: try: compmgr = manager_for_neighborhood(self.env, resource.neighborhood) except ResourceNotFound: # FIXME: raise ? return False else: return PermissionSystem(compmgr).check_permission( action, username, resource, perm) for policy in self.policies: decision = policy.check_permission(action, username, resource, perm) if decision is not None: if decision is False: self.log.debug("%s denies %s performing %s on %r", policy.__class__.__name__, username, action, resource) return decision self.log.debug("No policy allowed %s performing %s on %r", username, action, resource) return False # IPermissionRequestor methods def get_permission_actions(self): """Implement the global `TRAC_ADMIN` meta permission. Implements also the `EMAIL_VIEW` permission which allows for showing email addresses even if `[trac] show_email_addresses` is `false`. """ actions = self.get_actions(skip=self) actions.append('EMAIL_VIEW') return [('TRAC_ADMIN', actions), 'EMAIL_VIEW']
class GeoTicket(Component): implements(ICustomFieldProvider, ITicketManipulator, ITicketChangeListener, IRequestFilter, IRequestHandler, ITemplateProvider, ITemplateStreamFilter, IEnvironmentSetupParticipant) ### configuration options mandatory_location = BoolOption( 'geo', 'mandatory_location', 'false', "Enforce a mandatory and valid location field") google_api_key = Option( 'geo', 'google_api_key', '', "Google maps API key, available at http://code.google.com/apis/maps/signup.html" ) wms_url = Option('geo', 'wms_url', 'http://maps.opengeo.org/geowebcache/service/wms', "URL for the WMS") openlayers_url = Option('geo', 'openlayers_url', 'http://openlayers.org/api/2.8-rc2/OpenLayers.js', "URL of OpenLayers JS to use") # default viewing frame lat/lon min_lat = FloatOption('geo', 'min_lat', '-85.', "minimum latitude for default map display") max_lat = FloatOption('geo', 'max_lat', '85.', "maximum latitude for default map display") min_lon = FloatOption('geo', 'min_lon', '-180.', "minimum longitude for default map display") max_lon = FloatOption('geo', 'max_lon', '180.', "maximum longitude for default map display") # options for customizing map display feature_popup = Option('geo', 'feature_popup', '', "template for map feature popup") marker_style = OrderedExtensionsOption( 'geo', 'marker_style', IMapMarkerStyle, '', include_missing=False, doc="components to use to set feature style") ### method for ICustomFieldProvider # TODO : ensure CustomFieldProvider is enabled # XXX or, should CustomFieldProvider be used at all? def fields(self): return {'location': None} ### methods for ITicketManipulator def prepare_ticket(self, req, ticket, fields, actions): """Not currently called, but should be provided for future compatibility.""" def validate_ticket(self, req, ticket): """Validate a ticket after it's been populated from user input. Must return a list of `(field, message)` tuples, one for each problem detected. `field` can be `None` to indicate an overall problem with the ticket. Therefore, a return value of `[]` means everything is OK.""" location_changed = True # check for latitude and longitude in the request if 'latitude' in req.args and 'longitude' in req.args: lat = float(req.args['latitude']) lon = float(req.args['longitude']) else: lat = lon = None # compare ticket['location'] with stored version for existing tickets if ticket.id: location_changed = not ticket['location'] == Ticket( self.env, ticket.id)['location'] # get location string location = ticket['location'] if location is None: location = '' location = location.strip() # enforce the location field, if applicable if not location: if location_changed and ticket.id: self.delete_location(ticket.id) if self.mandatory_location: return [('location', 'Please enter a location')] else: return [] # do nothing if the location isn't changed if not location_changed: return [] # XXX blindly assume UTF-8 try: location = location.encode('utf-8') except UnicodeEncodeError: raise # geolocate the address if lat is not None and lon is not None: if ticket.id: self.set_location(ticket.id, lat, lon) else: # XXX what if not ticket.id ? ticket.latitude = lat ticket.longitude = lon else: try: ticket['location'], (lat, lon) = self.geolocate(location) if ticket.id: self.set_location(ticket.id, lat, lon) except GeolocationException, e: if location_changed and ticket.id: self.delete_location(ticket.id) if len(e.locations) > 1: return [('location', str(e))] if self.mandatory_location: return [('location', str(e))] # store the error in a cookie as add_warning is clobbered # in the post-POST redirect req.session['geolocation_error'] = e.html() req.session.save() return []