1# -*- coding: utf-8 -*-
2"""
3    sphinxcontrib.websupport.core
4    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5
6    Base Module for web support functions.
7
8    :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS.
9    :license: BSD, see LICENSE for details.
10"""
11
12import html
13import sys
14import pickle
15import posixpath
16from os import path
17
18from jinja2 import Environment, FileSystemLoader
19from docutils.core import publish_parts
20
21from sphinx.locale import _
22from sphinx.util.docutils import docutils_namespace
23from sphinx.util.osutil import ensuredir
24from sphinxcontrib.websupport import errors
25from sphinxcontrib.websupport import package_dir
26from sphinxcontrib.websupport.search import BaseSearch, SEARCH_ADAPTERS
27from sphinxcontrib.websupport.storage import StorageBackend
28
29try:
30    from sphinxcontrib.serializinghtml.jsonimpl import dumps as dump_json
31except ImportError:
32    from sphinx.util.jsonimpl import dumps as dump_json
33
34if False:
35    # For type annotation
36    from typing import Dict  # NOQA
37
38
39class WebSupport(object):
40    """The main API class for the web support package. All interactions
41    with the web support package should occur through this class.
42    """
43    def __init__(self,
44                 srcdir=None,      # only required for building
45                 builddir='',      # the dir with data/static/doctrees subdirs
46                 datadir=None,     # defaults to builddir/data
47                 staticdir=None,   # defaults to builddir/static
48                 doctreedir=None,  # defaults to builddir/doctrees
49                 search=None,      # defaults to no search
50                 storage=None,     # defaults to SQLite in datadir
51                 buildername='websupport',
52                 confoverrides={},
53                 status=sys.stdout,
54                 warning=sys.stderr,
55                 moderation_callback=None,
56                 allow_anonymous_comments=True,
57                 docroot='',
58                 staticroot='static',
59                 ):
60        # directories
61        self.srcdir = srcdir
62        self.builddir = builddir
63        self.outdir = path.join(builddir, 'data')
64        self.datadir = datadir or self.outdir
65        self.staticdir = staticdir or path.join(self.builddir, 'static')
66        self.doctreedir = doctreedir or path.join(self.builddir, 'doctrees')
67        # web server virtual paths
68        self.staticroot = staticroot.strip('/')
69        self.docroot = docroot.strip('/')
70
71        self.buildername = buildername
72        self.confoverrides = confoverrides
73
74        self.status = status
75        self.warning = warning
76        self.moderation_callback = moderation_callback
77        self.allow_anonymous_comments = allow_anonymous_comments
78
79        self._init_templating()
80        self._init_search(search)
81        self._init_storage(storage)
82
83        self._globalcontext = None  # type: ignore
84
85        self._make_base_comment_options()
86
87        extensions = self.confoverrides.setdefault('extensions', [])
88        extensions.append('sphinxcontrib.websupport.builder')
89
90    def _init_storage(self, storage):
91        if isinstance(storage, StorageBackend):
92            self.storage = storage
93        else:
94            # If a StorageBackend isn't provided, use the default
95            # SQLAlchemy backend.
96            from sphinxcontrib.websupport.storage.sqlalchemystorage \
97                import SQLAlchemyStorage
98            if not storage:
99                # no explicit DB path given; create default sqlite database
100                db_path = path.join(self.datadir, 'db', 'websupport.db')
101                ensuredir(path.dirname(db_path))
102                storage = 'sqlite:///' + db_path
103            self.storage = SQLAlchemyStorage(storage)
104
105    def _init_templating(self):
106        loader = FileSystemLoader(path.join(package_dir, 'templates'))
107        self.template_env = Environment(loader=loader)
108
109    def _init_search(self, search):
110        if isinstance(search, BaseSearch):
111            self.search = search
112        else:
113            mod, cls = SEARCH_ADAPTERS[search or 'null']
114            mod = 'sphinxcontrib.websupport.search.' + mod
115            SearchClass = getattr(__import__(mod, None, None, [cls]), cls)
116            search_path = path.join(self.datadir, 'search')
117            self.search = SearchClass(search_path)
118        self.results_template = \
119            self.template_env.get_template('searchresults.html')
120
121    def build(self):
122        """Build the documentation. Places the data into the `outdir`
123        directory. Use it like this::
124
125            support = WebSupport(srcdir, builddir, search='xapian')
126            support.build()
127
128        This will read reStructured text files from `srcdir`. Then it will
129        build the pickles and search index, placing them into `builddir`.
130        It will also save node data to the database.
131        """
132        if not self.srcdir:
133            raise RuntimeError('No srcdir associated with WebSupport object')
134
135        with docutils_namespace():
136            from sphinx.application import Sphinx
137            app = Sphinx(self.srcdir, self.srcdir, self.outdir, self.doctreedir,
138                         self.buildername, self.confoverrides, status=self.status,
139                         warning=self.warning)
140            app.builder.set_webinfo(self.staticdir, self.staticroot,  # type: ignore
141                                    self.search, self.storage)
142
143            self.storage.pre_build()
144            app.build()
145            self.storage.post_build()
146
147    def get_globalcontext(self):
148        """Load and return the "global context" pickle."""
149        if not self._globalcontext:
150            infilename = path.join(self.datadir, 'globalcontext.pickle')
151            with open(infilename, 'rb') as f:
152                self._globalcontext = pickle.load(f)
153        return self._globalcontext
154
155    def get_document(self, docname, username='', moderator=False):
156        """Load and return a document from a pickle. The document will
157        be a dict object which can be used to render a template::
158
159            support = WebSupport(datadir=datadir)
160            support.get_document('index', username, moderator)
161
162        In most cases `docname` will be taken from the request path and
163        passed directly to this function. In Flask, that would be something
164        like this::
165
166            @app.route('/<path:docname>')
167            def index(docname):
168                username = g.user.name if g.user else ''
169                moderator = g.user.moderator if g.user else False
170                try:
171                    document = support.get_document(docname, username,
172                                                    moderator)
173                except DocumentNotFoundError:
174                    abort(404)
175                render_template('doc.html', document=document)
176
177        The document dict that is returned contains the following items
178        to be used during template rendering.
179
180        * **body**: The main body of the document as HTML
181        * **sidebar**: The sidebar of the document as HTML
182        * **relbar**: A div containing links to related documents
183        * **title**: The title of the document
184        * **css**: Links to css files used by Sphinx
185        * **script**: Javascript containing comment options
186
187        This raises :class:`~sphinxcontrib.websupport.errors.DocumentNotFoundError`
188        if a document matching `docname` is not found.
189
190        :param docname: the name of the document to load.
191        """
192        docpath = path.join(self.datadir, 'pickles', docname)
193        if path.isdir(docpath):
194            infilename = docpath + '/index.fpickle'
195            if not docname:
196                docname = 'index'
197            else:
198                docname += '/index'
199        else:
200            infilename = docpath + '.fpickle'
201
202        try:
203            with open(infilename, 'rb') as f:
204                document = pickle.load(f)
205        except IOError:
206            raise errors.DocumentNotFoundError(
207                'The document "%s" could not be found' % docname)
208
209        comment_opts = self._make_comment_options(username, moderator)
210        comment_meta = self._make_metadata(
211            self.storage.get_metadata(docname, moderator))
212
213        document['script'] = comment_opts + comment_meta + document['script']
214        return document
215
216    def get_search_results(self, q):
217        """Perform a search for the query `q`, and create a set
218        of search results. Then render the search results as html and
219        return a context dict like the one created by
220        :meth:`get_document`::
221
222            document = support.get_search_results(q)
223
224        :param q: the search query
225        """
226        results = self.search.query(q)
227        ctx = {
228            'q': q,
229            'search_performed': True,
230            'search_results': results,
231            'docroot': '../',  # XXX
232            '_': _,
233        }
234        document = {
235            'body': self.results_template.render(ctx),
236            'title': 'Search Results',
237            'sidebar': '',
238            'relbar': ''
239        }
240        return document
241
242    def get_data(self, node_id, username=None, moderator=False):
243        """Get the comments and source associated with `node_id`. If
244        `username` is given vote information will be included with the
245        returned comments. The default CommentBackend returns a dict with
246        two keys, *source*, and *comments*. *source* is raw source of the
247        node and is used as the starting point for proposals a user can
248        add. *comments* is a list of dicts that represent a comment, each
249        having the following items:
250
251        ============= ======================================================
252        Key           Contents
253        ============= ======================================================
254        text          The comment text.
255        username      The username that was stored with the comment.
256        id            The comment's unique identifier.
257        rating        The comment's current rating.
258        age           The time in seconds since the comment was added.
259        time          A dict containing time information. It contains the
260                      following keys: year, month, day, hour, minute, second,
261                      iso, and delta. `iso` is the time formatted in ISO
262                      8601 format. `delta` is a printable form of how old
263                      the comment is (e.g. "3 hours ago").
264        vote          If `user_id` was given, this will be an integer
265                      representing the vote. 1 for an upvote, -1 for a
266                      downvote, or 0 if unvoted.
267        node          The id of the node that the comment is attached to.
268                      If the comment's parent is another comment rather than
269                      a node, this will be null.
270        parent        The id of the comment that this comment is attached
271                      to if it is not attached to a node.
272        children      A list of all children, in this format.
273        proposal_diff An HTML representation of the differences between the
274                      the current source and the user's proposed source.
275        ============= ======================================================
276
277        :param node_id: the id of the node to get comments for.
278        :param username: the username of the user viewing the comments.
279        :param moderator: whether the user is a moderator.
280        """
281        return self.storage.get_data(node_id, username, moderator)
282
283    def delete_comment(self, comment_id, username='', moderator=False):
284        """Delete a comment.
285
286        If `moderator` is True, the comment and all descendants will be deleted
287        from the database, and the function returns ``True``.
288
289        If `moderator` is False, the comment will be marked as deleted (but not
290        removed from the database so as not to leave any comments orphaned), but
291        only if the `username` matches the `username` on the comment.  The
292        username and text files are replaced with "[deleted]" .  In this case,
293        the function returns ``False``.
294
295        This raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError`
296        if moderator is False and `username` doesn't match username on the
297        comment.
298
299        :param comment_id: the id of the comment to delete.
300        :param username: the username requesting the deletion.
301        :param moderator: whether the requestor is a moderator.
302        """
303        return self.storage.delete_comment(comment_id, username, moderator)
304
305    def add_comment(self, text, node_id='', parent_id='', displayed=True,
306                    username=None, time=None, proposal=None,
307                    moderator=False):
308        """Add a comment to a node or another comment. Returns the comment
309        in the same format as :meth:`get_comments`. If the comment is being
310        attached to a node, pass in the node's id (as a string) with the
311        node keyword argument::
312
313            comment = support.add_comment(text, node_id=node_id)
314
315        If the comment is the child of another comment, provide the parent's
316        id (as a string) with the parent keyword argument::
317
318            comment = support.add_comment(text, parent_id=parent_id)
319
320        If you would like to store a username with the comment, pass
321        in the optional `username` keyword argument::
322
323            comment = support.add_comment(text, node=node_id,
324                                          username=username)
325
326        :param parent_id: the prefixed id of the comment's parent.
327        :param text: the text of the comment.
328        :param displayed: for moderation purposes
329        :param username: the username of the user making the comment.
330        :param time: the time the comment was created, defaults to now.
331        """
332        if username is None:
333            if self.allow_anonymous_comments:
334                username = 'Anonymous'
335            else:
336                raise errors.UserNotAuthorizedError()
337        parsed = self._parse_comment_text(text)
338        comment = self.storage.add_comment(parsed, displayed, username,
339                                           time, proposal, node_id,
340                                           parent_id, moderator)
341        comment['original_text'] = text
342        if not displayed and self.moderation_callback:
343            self.moderation_callback(comment)
344        return comment
345
346    def process_vote(self, comment_id, username, value):
347        """Process a user's vote. The web support package relies
348        on the API user to perform authentication. The API user will
349        typically receive a comment_id and value from a form, and then
350        make sure the user is authenticated. A unique username  must be
351        passed in, which will also be used to retrieve the user's past
352        voting data. An example, once again in Flask::
353
354            @app.route('/docs/process_vote', methods=['POST'])
355            def process_vote():
356                if g.user is None:
357                    abort(401)
358                comment_id = request.form.get('comment_id')
359                value = request.form.get('value')
360                if value is None or comment_id is None:
361                    abort(400)
362                support.process_vote(comment_id, g.user.name, value)
363                return "success"
364
365        :param comment_id: the comment being voted on
366        :param username: the unique username of the user voting
367        :param value: 1 for an upvote, -1 for a downvote, 0 for an unvote.
368        """
369        value = int(value)
370        if not -1 <= value <= 1:
371            raise ValueError('vote value %s out of range (-1, 1)' % value)
372        self.storage.process_vote(comment_id, username, value)
373
374    def update_username(self, old_username, new_username):
375        """To remain decoupled from a webapp's authentication system, the
376        web support package stores a user's username with each of their
377        comments and votes. If the authentication system allows a user to
378        change their username, this can lead to stagnate data in the web
379        support system. To avoid this, each time a username is changed, this
380        method should be called.
381
382        :param old_username: The original username.
383        :param new_username: The new username.
384        """
385        self.storage.update_username(old_username, new_username)
386
387    def accept_comment(self, comment_id, moderator=False):
388        """Accept a comment that is pending moderation.
389
390        This raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError`
391        if moderator is False.
392
393        :param comment_id: The id of the comment that was accepted.
394        :param moderator: Whether the user making the request is a moderator.
395        """
396        if not moderator:
397            raise errors.UserNotAuthorizedError()
398        self.storage.accept_comment(comment_id)
399
400    def _make_base_comment_options(self):
401        """Helper method to create the part of the COMMENT_OPTIONS javascript
402        that remains the same throughout the lifetime of the
403        :class:`~sphinxcontrib.websupport.WebSupport` object.
404        """
405        self.base_comment_opts = {}  # type: Dict[str, str]
406
407        if self.docroot != '':
408            comment_urls = [
409                ('addCommentURL', '_add_comment'),
410                ('getCommentsURL', '_get_comments'),
411                ('processVoteURL', '_process_vote'),
412                ('acceptCommentURL', '_accept_comment'),
413                ('deleteCommentURL', '_delete_comment')
414            ]
415            for key, value in comment_urls:
416                self.base_comment_opts[key] = \
417                    '/' + posixpath.join(self.docroot, value)
418        if self.staticroot != 'static':
419            static_urls = [
420                ('commentImage', 'comment.png'),
421                ('closeCommentImage', 'comment-close.png'),
422                ('loadingImage', 'ajax-loader.gif'),
423                ('commentBrightImage', 'comment-bright.png'),
424                ('upArrow', 'up.png'),
425                ('upArrowPressed', 'up-pressed.png'),
426                ('downArrow', 'down.png'),
427                ('downArrowPressed', 'down-pressed.png')
428            ]
429            for key, value in static_urls:
430                self.base_comment_opts[key] = \
431                    '/' + posixpath.join(self.staticroot, '_static', value)
432
433    def _make_comment_options(self, username, moderator):
434        """Helper method to create the parts of the COMMENT_OPTIONS
435        javascript that are unique to each request.
436
437        :param username: The username of the user making the request.
438        :param moderator: Whether the user making the request is a moderator.
439        """
440        rv = self.base_comment_opts.copy()
441        if username:
442            rv.update({
443                'voting': True,
444                'username': username,
445                'moderator': moderator,
446            })
447        return '''\
448        <script type="text/javascript">
449        var COMMENT_OPTIONS = %s;
450        </script>
451        ''' % dump_json(rv)
452
453    def _make_metadata(self, data):
454        return '''\
455        <script type="text/javascript">
456        var COMMENT_METADATA = %s;
457        </script>
458        ''' % dump_json(data)
459
460    def _parse_comment_text(self, text):
461        settings = {'file_insertion_enabled': False,
462                    'raw_enabled': False,
463                    'output_encoding': 'unicode'}
464        try:
465            ret = publish_parts(text, writer_name='html',
466                                settings_overrides=settings)['fragment']
467        except Exception:
468            ret = html.escape(text)
469        return ret
470