edgewall/trac

View on GitHub
trac/wiki/api.py

Summary

Maintainability
D
2 days
Test Coverage
# -*- coding: utf-8 -*-
#
# Copyright (C) 2003-2023 Edgewall Software
# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at https://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at https://trac.edgewall.org/log/.
#
# Author: Jonas Borgström <jonas@edgewall.com>
#         Christopher Lenz <cmlenz@gmx.de>

import re

from trac.cache import cached
from trac.config import BoolOption, ListOption
from trac.core import *
from trac.resource import IResourceManager
from trac.util.html import is_safe_origin, tag
from trac.util.text import unquote_label
from trac.util.translation import _
from trac.wiki.parser import WikiParser


class IWikiChangeListener(Interface):
    """Components that want to get notified about the creation,
    deletion and modification of wiki pages should implement that
    interface.
    """

    def wiki_page_added(page):
        """Called whenever a new Wiki page is added."""

    def wiki_page_changed(page, version, t, comment, author):
        """Called when a page has been modified."""

    def wiki_page_deleted(page):
        """Called when a page has been deleted."""

    def wiki_page_version_deleted(page):
        """Called when a version of a page has been deleted."""

    def wiki_page_renamed(page, old_name):
        """Called when a page has been renamed."""

    def wiki_page_comment_modified(page, old_comment):
        """Called when a page comment has been modified."""


class IWikiPageManipulator(Interface):
    """Components that need to do specific pre- and post- processing of
    wiki page changes have to implement this interface.

    Unlike change listeners, a manipulator can reject changes being
    committed to the database.
    """

    def prepare_wiki_page(req, page, fields):
        """Validate a wiki page before rendering it.

        :param page: is the `WikiPage` being viewed.

        :param fields: is a dictionary which contains the wiki `text`
          of the page, initially identical to `page.text` but it can
          eventually be transformed in place before being used as
          input to the formatter.
        """

    def validate_wiki_page(req, page):
        """Validate a wiki page after it's been populated from user input.

        :param page: is the `WikiPage` being edited.

        :return: a list of `(field, message)` tuples, one for each
          problem detected. `field` can be `None` to indicate an
          overall problem with the page. Therefore, a return value of
          `[]` means everything is OK.
        """


class IWikiMacroProvider(Interface):
    """Augment the Wiki markup with new Wiki macros.

    .. versionchanged :: 0.12
       new Wiki processors can also be added that way.
    """

    def get_macros():
        """Return an iterable that provides the names of the provided macros.
        """

    def get_macro_description(name):
        """Return a tuple of a domain name to translate and plain text
        description of the macro or only the description with the specified
        name.

        .. versionchanged :: 1.0
           `get_macro_description` can return a domain to translate the
           description.

        .. versionchanged :: 1.3.6
           the macro will be hidden from the macro index (`[[MacroList]]`)
           if `None` is returned.
        """

    def is_inline(content):
        """Return `True` if the content generated is an inline XHTML element.

        .. versionadded :: 1.0
        """

    def expand_macro(formatter, name, content, args=None):
        """Called by the formatter when rendering the parsed wiki text.

        .. versionadded:: 0.11

        .. versionchanged:: 0.12
           added the `args` parameter

        :param formatter: the wiki `Formatter` currently processing
          the wiki markup

        :param name: is the name by which the macro has been called;
          remember that via `get_macros`, multiple names could be
          associated to this macros. Note that the macro names are
          case sensitive.

        :param content: is the content of the macro call. When called
          using macro syntax (`[[Macro(content)]]`), this is the
          string contained between parentheses, usually containing
          macro arguments. When called using wiki processor syntax
          (`{{{!#Macro ...}}}`), it is the content of the processor
          block, that is, the text starting on the line following the
          macro name.

        :param args: will be a dictionary containing the named
          parameters passed when using the Wiki processor syntax.

          The named parameters can be specified when calling the macro
          using the wiki processor syntax::

            {{{#!Macro arg1=value1 arg2="value 2"`
            ... some content ...
            }}}

          In this example, `args` will be
          `{'arg1': 'value1', 'arg2': 'value 2'}`
          and `content` will be `"... some content ..."`.

          If no named parameters are given like in::

            {{{#!Macro
            ...
            }}}

          then `args` will be `{}`. That makes it possible to
          differentiate the above situation from a call
          made using the macro syntax::

             [[Macro(arg1=value1, arg2="value 2", ... some content...)]]

          in which case `args` will always be `None`.  Here `content`
          will be the
          `"arg1=value1, arg2="value 2", ... some content..."` string.
          If like in this example, `content` is expected to contain
          some arguments and named parameters, one can use the
          `parse_args` function to conveniently extract them.
        """


class IWikiSyntaxProvider(Interface):
    """Enrich the Wiki syntax with new markup."""

    def get_wiki_syntax():
        """Return an iterable that provides additional wiki syntax.

        Additional wiki syntax correspond to a pair of `(regexp, cb)`,
        the `regexp` for the additional syntax and the callback `cb`
        which will be called if there's a match.  That function is of
        the form `cb(formatter, ns, match)`.
        """

    def get_link_resolvers():
        """Return an iterable over `(namespace, formatter)` tuples.

        Each formatter should be a function of the form::

          def format(formatter, ns, target, label, fullmatch=None):
              pass

        and should return some HTML fragment. The `label` is already
        HTML escaped, whereas the `target` is not. The `fullmatch`
        argument is optional, and is bound to the regexp match object
        for the link.
        """

def parse_args(args, strict=True):
    r"""Utility for parsing macro "content" and splitting them into arguments.

    The content is split along commas, unless they are escaped with a
    backquote (see example below).

    :param args: a string containing macros arguments
    :param strict: if `True`, only Python-like identifiers will be
                   recognized as keyword arguments

    Example usage::

      >>> parse_args('')
      ([], {})
      >>> parse_args('Some text')
      (['Some text'], {})
      >>> parse_args(r'Some text, mode= 3, some other arg\, with a comma.')
      (['Some text', ' some other arg, with a comma.'], {'mode': ' 3'})
      >>> sorted(parse_args('milestone=milestone1,status!=closed',
      ...                   strict=False)[1].items())
      [('milestone', 'milestone1'), ('status!', 'closed')]

    """
    largs, kwargs = [], {}
    if args:
        for arg in re.split(r'(?<!\\),', args):
            arg = arg.replace(r'\,', ',')
            if strict:
                m = re.match(r'\s*[a-zA-Z_]\w+=', arg)
            else:
                m = re.match(r'\s*[^=]+=', arg)
            if m:
                kw = arg[:m.end()-1].strip()
                kwargs[kw] = arg[m.end():]
            else:
                largs.append(arg)
    return largs, kwargs


def validate_page_name(pagename):
    """Utility for validating wiki page name.

    :param pagename: wiki page name to validate
    """
    return pagename and \
           all(part not in ('', '.', '..') for part in pagename.split('/'))


class WikiSystem(Component):
    """Wiki system manager."""

    implements(IResourceManager, IWikiSyntaxProvider)

    change_listeners = ExtensionPoint(IWikiChangeListener)
    macro_providers = ExtensionPoint(IWikiMacroProvider)
    syntax_providers = ExtensionPoint(IWikiSyntaxProvider)

    realm = 'wiki'
    START_PAGE = 'WikiStart'
    TITLE_INDEX_PAGE = 'TitleIndex'

    ignore_missing_pages = BoolOption('wiki', 'ignore_missing_pages', 'false',
        """Enable/disable highlighting CamelCase links to missing pages.
        """)

    split_page_names = BoolOption('wiki', 'split_page_names', 'false',
        """Enable/disable splitting the WikiPageNames with space characters.
        """)

    render_unsafe_content = BoolOption('wiki', 'render_unsafe_content', 'false',
        """Enable/disable the use of unsafe HTML tags such as `<script>` or
        `<embed>` with the HTML [wiki:WikiProcessors WikiProcessor].

        For public sites where anonymous users can edit the wiki it is
        recommended to leave this option disabled.
        """)

    safe_schemes = ListOption('wiki', 'safe_schemes',
        'cvs, file, ftp, git, irc, http, https, news, sftp, smb, ssh, svn, '
        'svn+ssh',
        doc="""List of URI schemes considered "safe", that will be rendered as
        external links even if `[wiki] render_unsafe_content` is `false`.
        """)

    safe_origins = ListOption('wiki', 'safe_origins',
        'data:',
        doc="""List of URIs considered "safe cross-origin", that will be
        rendered as `img` element without `crossorigin="anonymous"` attribute
        or used in `url()` of inline style attribute even if
        `[wiki] render_unsafe_content` is `false` (''since 1.0.15'').

        To make any origins safe, specify "*" in the list.""")

    @cached
    def pages(self):
        """Return the names of all existing wiki pages."""
        return {name for name,
                     in self.env.db_query("SELECT DISTINCT name FROM wiki")}

    # Public API

    def get_pages(self, prefix=None):
        """Iterate over the names of existing Wiki pages.

        :param prefix: if given, only names that start with that
          prefix are included.
        """
        for page in self.pages:
            if not prefix or page.startswith(prefix):
                yield page

    def has_page(self, pagename):
        """Whether a page with the specified name exists."""
        return pagename.rstrip('/') in self.pages

    def is_safe_origin(self, uri, req=None):
        return is_safe_origin(self.safe_origins, uri, req=req)

    def resolve_relative_name(self, pagename, referrer):
        """Resolves a pagename relative to a referrer pagename."""
        if pagename.startswith(('./', '../')) or pagename in ('.', '..'):
            return self._resolve_relative_name(pagename, referrer)
        return pagename

    # IWikiSyntaxProvider methods

    XML_NAME = r"[\w:](?<!\d)(?:[\w:.-]*[\w-])?"
    # See http://www.w3.org/TR/REC-xml/#id,
    # here adapted to exclude terminal "." and ":" characters

    PAGE_SPLIT_RE = re.compile(r"([a-z])([A-Z])(?=[a-z])")

    Lu = ''.join(chr(c) for c in range(0x10000) if chr(c).isupper())
    Ll = ''.join(chr(c) for c in range(0x10000) if chr(c).islower())

    def format_page_name(self, page, split=False):
        if split or self.split_page_names:
            return self.PAGE_SPLIT_RE.sub(r"\1 \2", page)
        return page

    def make_label_from_target(self, target):
        """Create a label from a wiki target.

        A trailing fragment and query string is stripped. Then, leading ./,
        ../ and / elements are stripped, except when this would lead to an
        empty label. Finally, if `split_page_names` is true, the label
        is split accordingly.
        """
        label = target.split('#', 1)[0].split('?', 1)[0]
        if not label:
            return target
        components = label.split('/')
        for i, comp in enumerate(components):
            if comp not in ('', '.', '..'):
                label = '/'.join(components[i:])
                break
        return self.format_page_name(label)

    def get_wiki_syntax(self):
        wiki_page_name = (
            r"(?:[%(upper)s](?:[%(lower)s])+/?){2,}" # wiki words
            r"(?:@[0-9]+)?"                          # optional version
            r"(?:#%(xml)s)?"                         # optional fragment id
            r"(?=:(?:\Z|\s)|[^:\w%(upper)s%(lower)s]|\s|\Z)"
            # what should follow it
            % {'upper': self.Lu, 'lower': self.Ll, 'xml': self.XML_NAME})

        # Regular WikiPageNames
        def wikipagename_link(formatter, match, fullmatch):
            return self._format_link(formatter, 'wiki', match,
                                     self.format_page_name(match),
                                     self.ignore_missing_pages, match)

        # Start after any non-word char except '/', with optional relative or
        # absolute prefix
        yield (r"!?(?<![\w/])(?:\.?\.?/)*"
               + wiki_page_name, wikipagename_link)

        # [WikiPageNames with label]
        def wikipagename_with_label_link(formatter, match, fullmatch):
            page = fullmatch.group('wiki_page')
            label = fullmatch.group('wiki_label')
            return self._format_link(formatter, 'wiki', page, label.strip(),
                                     self.ignore_missing_pages, match)
        yield (r"!?\[(?P<wiki_page>%s)\s+(?P<wiki_label>%s|[^\]]+)\]"
               % (wiki_page_name, WikiParser.QUOTED_STRING),
               wikipagename_with_label_link)

        # MoinMoin's ["internal free link"] and ["free link" with label]
        def internal_free_link(fmt, m, fullmatch):
            page = fullmatch.group('ifl_page')[1:-1]
            label = fullmatch.group('ifl_label')
            if label is None:
                label = self.make_label_from_target(page)
            return self._format_link(fmt, 'wiki', page, label.strip(), False)
        yield (r"!?\[(?P<ifl_page>%s)(?:\s+(?P<ifl_label>%s|[^\]]+))?\]"
               % (WikiParser.QUOTED_STRING, WikiParser.QUOTED_STRING),
               internal_free_link)

    def get_link_resolvers(self):
        def link_resolver(formatter, ns, target, label, fullmatch=None):
            if fullmatch is not None:
                # If no explicit label was specified for a [wiki:...] link,
                # generate a "nice" label instead of keeping the label
                # generated by the Formatter (usually the target itself).
                groups = fullmatch.groupdict()
                if groups.get('lns') and not groups.get('label'):
                    label = self.make_label_from_target(target)
            return self._format_link(formatter, ns, target, label, False)
        yield ('wiki', link_resolver)

    def _format_link(self, formatter, ns, pagename, label, ignore_missing,
                     original_label=None):
        pagename, query, fragment = formatter.split_link(pagename)
        version = None
        if '@' in pagename:
            pagename, version = pagename.split('@', 1)
        if version and query:
            query = '&' + query[1:]
        pagename = pagename.rstrip('/') or self.START_PAGE
        referrer = ''
        if formatter.resource and formatter.resource.realm == self.realm:
            referrer = formatter.resource.id
        if pagename.startswith('/'):
            pagename = pagename.lstrip('/')
        elif pagename.startswith(('./', '../')) or pagename in ('.', '..'):
            pagename = self._resolve_relative_name(pagename, referrer)
        else:
            pagename = self._resolve_scoped_name(pagename, referrer)
        label = unquote_label(label)
        if 'WIKI_VIEW' in formatter.perm(self.realm, pagename, version):
            href = formatter.href.wiki(pagename, version=version) + query \
                   + fragment
            if self.has_page(pagename):
                return tag.a(label, href=href, class_='wiki')
            else:
                if ignore_missing:
                    return original_label or label
                if 'WIKI_CREATE' in \
                        formatter.perm(self.realm, pagename, version):
                    return tag.a(label, class_='missing wiki',
                                 href=href, rel='nofollow')
                else:
                    return tag.a(label, class_='missing wiki')
        elif ignore_missing and not self.has_page(pagename):
            return original_label or label
        else:
            return tag.a(label, class_='forbidden wiki',
                         title=_("no permission to view this wiki page"))

    def _resolve_relative_name(self, pagename, referrer):
        base = referrer.split('/')
        components = pagename.split('/')
        for i, comp in enumerate(components):
            if comp == '..':
                if base:
                    base.pop()
            elif comp != '.':
                base.extend(components[i:])
                break
        return '/'.join(base)

    def _resolve_scoped_name(self, pagename, referrer):
        referrer = referrer.split('/')
        if len(referrer) == 1:           # Non-hierarchical referrer
            return pagename
        # Test for pages with same name, higher in the hierarchy
        for i in range(len(referrer) - 1, 0, -1):
            name = '/'.join(referrer[:i]) + '/' + pagename
            if self.has_page(name):
                return name
        if self.has_page(pagename):
            return pagename
        # If we are on First/Second/Third, and pagename is Second/Other,
        # resolve to First/Second/Other instead of First/Second/Second/Other
        # See https://trac.edgewall.org/ticket/4507#comment:12
        if '/' in pagename:
            (first, rest) = pagename.split('/', 1)
            for (i, part) in enumerate(referrer):
                if first == part:
                    anchor = '/'.join(referrer[:i + 1])
                    if self.has_page(anchor):
                        return anchor + '/' + rest
        # Assume the user wants a sibling of referrer
        return '/'.join(referrer[:-1]) + '/' + pagename

    # IResourceManager methods

    def get_resource_realms(self):
        yield self.realm

    def get_resource_description(self, resource, format, **kwargs):
        """
        >>> from trac.test import EnvironmentStub
        >>> from trac.resource import Resource, get_resource_description
        >>> env = EnvironmentStub()
        >>> main = Resource('wiki', 'WikiStart')
        >>> get_resource_description(env, main)
        'WikiStart'

        >>> get_resource_description(env, main(version=3))
        'WikiStart'

        >>> get_resource_description(env, main(version=3), format='summary')
        'WikiStart'

        >>> env.config['wiki'].set('split_page_names', 'true')
        >>> get_resource_description(env, main(version=3))
        'Wiki Start'
        """
        return self.format_page_name(resource.id)

    def resource_exists(self, resource):
        """
        >>> from trac.test import EnvironmentStub
        >>> from trac.resource import Resource, resource_exists
        >>> env = EnvironmentStub()

        >>> resource_exists(env, Resource('wiki', 'WikiStart'))
        False

        >>> from trac.wiki.model import WikiPage
        >>> main = WikiPage(env, 'WikiStart')
        >>> main.text = 'some content'
        >>> main.save('author', 'no comment')
        >>> resource_exists(env, main.resource)
        True
        """
        if resource.version is None:
            return resource.id in self.pages
        return bool(self.env.db_query(
            "SELECT name FROM wiki WHERE name=%s AND version=%s",
            (resource.id, resource.version)))