view src/server/blog.py @ 726:e949b7c7ed9c

server side (blog): fixes unicode error
author souliane <souliane@mailoo.org>
date Mon, 21 Sep 2015 12:01:34 +0200
parents c1abaa91a121
children 3bd097380da7
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Libervia: a Salut à Toi frontend
# Copyright (C) 2011, 2012, 2013, 2014, 2015 Jérôme Poisson <goffi@goffi.org>
# Copyright (C) 2013, 2014, 2015 Adrien Cossa <souliane@mailoo.org>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from sat.core.i18n import _, D_
from sat_frontends.tools.strings import addURLToText
from sat.core.log import getLogger
log = getLogger(__name__)

from twisted.internet import defer
from twisted.web import server
from twisted.web.resource import Resource
from twisted.words.protocols.jabber.jid import JID
from jinja2 import Environment, PackageLoader
from datetime import datetime
from sys import path
import uuid
import re
import os

from libervia.server.html_tools import sanitizeHtml, convertNewLinesToXHTML
from libervia.server.constants import Const as C


NS_MICROBLOG = 'urn:xmpp:microblog:0'


class TemplateProcessor(object):

    THEME = 'default'

    def __init__(self, host):
        self.host = host

        # add Libervia's themes directory to the python path
        path.append(os.path.dirname(os.path.normpath(self.host.themes_dir)))
        themes = os.path.basename(os.path.normpath(self.host.themes_dir))
        self.env = Environment(loader=PackageLoader(themes, self.THEME))

    def useTemplate(self, request, tpl, data=None):
        root_url = '../' * len(request.postpath)
        theme_url = os.path.join(root_url, 'themes', self.THEME)

        data_ = {'images': os.path.join(theme_url, 'images'),
                 'styles': os.path.join(theme_url, 'styles'),
                 }
        if data:
            data_.update(data)

        template = self.env.get_template('%s.html' % tpl)
        return template.render(**data_).encode('utf-8')


class MicroBlog(Resource, TemplateProcessor):
    isLeaf = True

    def __init__(self, host):
        self.host = host
        Resource.__init__(self)
        TemplateProcessor.__init__(self, host)
        self.host.bridge.register('entityDataUpdated', self.entityDataUpdatedCb)
        self.host.bridge.register('actionResult', self.actionResultCb)  # FIXME: actionResult is to be removed
        self.avatars_cache = {}
        self.waiting_deferreds = {}

    def entityDataUpdatedCb(self, entity_jid_s, key, value, dummy):
        """Retrieve the avatar we've been waiting for and fires the callback
        for self.getAvatar to return.

        @param entity_jid_s (str): JID of the contact
        @param key (str): entity data key
        @param value (str): entity data value
        @param dummy (str): that would be C.SERVICE_PROFILE
        """
        if key != 'avatar':
            return
        entity_jid_s = entity_jid_s.lower()
        log.debug(_(u"Received a new avatar for entity %s") % entity_jid_s)
        avatar = C.AVATARS_DIR + value
        self.avatars_cache[entity_jid_s] = avatar
        try:
            self.waiting_deferreds[entity_jid_s][1].callback(avatar)
            del self.waiting_deferreds[entity_jid_s]
        except KeyError:
            pass

    def actionResultCb(self, answer_type, action_id, data, dummy):
        """Fires the callback for self.getAvatar to return

        @param answer_type (str): 'SUPPRESS' or another value that we would ignore
        @param action_id (str): the request ID
        @param data (dict): ignored
        @param dummy (str): that would be C.SERVICE_PROFILE
        """
        # FIXME: actionResult is to be removed. For now we use it to get notified
        # when the requested vCard hasn't been found. Replace with the new system.
        if answer_type != 'SUPPRESS':
            return
        try:
            entity_jid_s = [key for (key, value) in self.waiting_deferreds.items() if value[0] == action_id][0]
        except IndexError:  # impossible to guess the entity
            return
        log.debug(_(u"Using default avatar for entity %s") % entity_jid_s)
        self.avatars_cache[entity_jid_s] = C.DEFAULT_AVATAR_URL
        self.waiting_deferreds[entity_jid_s][1].callback(C.DEFAULT_AVATAR_URL)
        del self.waiting_deferreds[entity_jid_s]

    def getAvatar(self, profile):
        """Get the avatar of the given profile

        @param profile(unicode): %(doc_profile)s
        @return: deferred avatar path, relative to the server's root
        """
        jid_s = (profile + '@' + self.host.bridge.getNewAccountDomain()).lower()
        if jid_s in self.avatars_cache:
            return defer.succeed(self.avatars_cache[jid_s])
        # FIXME: request_id is no more needed when actionResult is removed
        request_id = self.host.bridge.getCard(jid_s, C.SERVICE_PROFILE)
        self.waiting_deferreds[jid_s] = (request_id, defer.Deferred())
        return self.waiting_deferreds[jid_s][1]

    def render_GET(self, request):
        if not request.postpath:
            return self.useTemplate(request, "static_blog_error", {'message': "You must indicate a nickname"})

        prof_requested = request.postpath[0]
        #TODO : char check: only use alphanumeric chars + some extra(_,-,...) here
        prof_found = self.host.bridge.getProfileName(prof_requested)
        if not prof_found or prof_found == C.SERVICE_PROFILE:
            return self.useTemplate(request, "static_blog_error", {'message': "Invalid nickname"})

        d = defer.Deferred()
        JID(self.host.bridge.asyncGetParamA('JabberID', 'Connection', 'value', C.SERVER_SECURITY_LIMIT, prof_found, callback=d.callback, errback=d.errback))
        d.addCallbacks(lambda pub_jid_s: self.gotJID(pub_jid_s, request, prof_found))
        return server.NOT_DONE_YET

    def gotJID(self, pub_jid_s, request, profile):
        pub_jid = JID(pub_jid_s)

        self.parseURLParams(request)
        if request.item_id:
            item_ids = [request.item_id]
            max_items = 1
        else:
            item_ids = []
            max_items = int(request.extra_dict['rsm_max'])

        if request.atom:
            self.host.bridge.mbGetAtom(pub_jid.userhost(), NS_MICROBLOG, max_items, item_ids,
                                       request.extra_dict, C.SERVICE_PROFILE,
                                       lambda feed: self.render_atom_feed(feed, request),
                                       lambda failure: self.render_error_blog(failure, request, profile))
        elif request.item_id:
            self.getItemById(pub_jid, request.item_id, request.extra_dict,
                             request.extra_comments_dict, request, profile)
        else:
            self.getItems(pub_jid, max_items, request.extra_dict,
                          request.extra_comments_dict, request, profile)
            
    def parseURLParams(self, request):
        """Parse the request URL parameters.

        @param request: HTTP request
        """
        request.item_id = None
        request.atom = False

        if len(request.postpath) > 1:
            if request.postpath[1] == 'atom.xml':  # return the atom feed
                request.atom = True
            else:
                try:  # check if the given path is a valid UUID
                    uuid.UUID(request.postpath[1])
                    request.item_id = request.postpath[1]
                except ValueError:
                    pass

        self.parseURLParamsRSM(request)
        request.display_single = (request.item_id is not None) or int(request.extra_dict['rsm_max']) == 1
        self.parseURLParamsCommentsRSM(request)

    def parseURLParamsRSM(self, request):
        """Parse RSM request data from the URL parameters for main items

        @param request: HTTP request
        """
        request.extra_dict = {}
        if request.item_id:  # XXX: item_id and RSM are not compatible
            return
        try:
            request.extra_dict['rsm_max'] = request.args['max'][0]
        except (ValueError, KeyError):
            request.extra_dict['rsm_max'] = unicode(C.RSM_MAX_ITEMS)
        try:
            request.extra_dict['rsm_index'] = request.args['index'][0]
        except (ValueError, KeyError):
            try:
                request.extra_dict['rsm_before'] = request.args['before'][0]
            except KeyError:
                try:
                    request.extra_dict['rsm_after'] = request.args['after'][0]
                except KeyError:
                    pass

    def parseURLParamsCommentsRSM(self, request):
        """Parse RSM request data from the URL parameters for comments
        
        @param request: HTTP request
        """
        request.extra_comments_dict = {}
        if request.display_single:
            try:
                request.extra_comments_dict['rsm_max'] = request.args['comments_max'][0]
            except (ValueError, KeyError):
                request.extra_comments_dict['rsm_max'] = unicode(C.RSM_MAX_COMMENTS)
        else:
            request.extra_comments_dict['rsm_max'] = "0"

    def getItemById(self, pub_jid, item_id, extra_dict, extra_comments_dict, request, profile):
        """
        
        @param pub_jid (jid.JID): publisher JID
        @param item_id(unicode): ID of the item to retrieve
        @param extra_dict (dict): extra configuration for initial items only
        @param extra_comments_dict (dict): extra configuration for comments only
        @param request: HTTP request
        @param profile
        """

        def gotItems(items):
            items, metadata = items
            item = items[0]  # assume there's only one item

            def gotCount(items_bis):
                metadata_bis = items_bis[1]
                metadata['rsm_count'] = metadata_bis['rsm_count']
                index_key = "rsm_index" if metadata_bis.get("rsm_index") else "rsm_count"
                metadata['rsm_index'] = unicode(int(metadata_bis[index_key]) - 1)
                metadata['rsm_first'] = metadata['rsm_last'] = item["id"]
                
                def gotComments(comments):
                    # build the items as self.getItems would do it (and as self.render_html_blog expects them to be)
                    comments = [(item['comments_service'], item['comments_node'], "", comments[0], comments[1])]
                    self.render_html_blog([(item, comments)], metadata, request, profile)

                # get the comments
                max_comments = int(extra_comments_dict['rsm_max'])
                self.host.bridge.mbGet(item['comments_service'], item['comments_node'], max_comments, [],
                                       extra_comments_dict, C.SERVICE_PROFILE, callback=gotComments)

            # XXX: retrieve RSM information related to the main item. We can't do it while
            # retrieving the item, because item_ids and rsm should not be used together.
            self.host.bridge.mbGet(pub_jid.userhost(), NS_MICROBLOG, 1, [],
                                   {"rsm_max": "1", "rsm_after": item["id"]}, C.SERVICE_PROFILE, callback=gotCount)

        # get the main item
        self.host.bridge.mbGet(pub_jid.userhost(), NS_MICROBLOG, 1, [item_id],
                               extra_dict, C.SERVICE_PROFILE, callback=gotItems)

    def getItems(self, pub_jid, max_items, extra_dict, extra_comments_dict, request, profile):
        """
        
        @param pub_jid (jid.JID): publisher JID
        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
        @param extra_dict (dict): extra configuration for initial items only
        @param extra_comments_dict (dict): extra configuration for comments only
        @param request: HTTP request
        @param profile
        """
        def getResultCb(data, rt_session):
            remaining, results = data
            for result in results:
                service, node, failure, items, metadata = result
                if not failure:
                    self.render_html_blog(items, metadata, request, profile)
    
            if remaining:
                self._getResults(rt_session)
        
        def getResult(rt_session):
            self.host.bridge.mbGetFromManyWithCommentsRTResult(rt_session, C.SERVICE_PROFILE,
                                                               callback=lambda data: getResultCb(data, rt_session),
                                                               errback=lambda failure: self.render_error_blog(failure, request, profile))

        max_comments = int(extra_comments_dict['rsm_max'])
        self.host.bridge.mbGetFromManyWithComments(C.JID, [pub_jid.userhost()], max_items,
                                                   max_comments, extra_dict, extra_comments_dict,
                                                   C.SERVICE_PROFILE, callback=getResult)
        
    def render_html_blog(self, items, metadata, request, profile):
        """Retrieve the user parameters before actually rendering the static blog

        @param items(list[tuple(dict, list)]): same as in self.__render_html_blog
        @param metadata(dict): original node metadata
        @param request: HTTP request
        @param profile
        """
        d_list = []
        options = {}

        def getCallback(param_name):
            d = defer.Deferred()
            d.addCallback(lambda value: options.update({param_name: value}))
            d_list.append(d)
            return d.callback

        eb = lambda failure: self.render_error_blog(failure, request, profile)

        self.getAvatar(profile).addCallbacks(getCallback('avatar'), eb)
        for param_name in (C.STATIC_BLOG_PARAM_TITLE, C.STATIC_BLOG_PARAM_BANNER, C.STATIC_BLOG_PARAM_KEYWORDS, C.STATIC_BLOG_PARAM_DESCRIPTION):
            self.host.bridge.asyncGetParamA(param_name, C.STATIC_BLOG_KEY, 'value', C.SERVER_SECURITY_LIMIT, profile, callback=getCallback(param_name), errback=eb)

        cb = lambda dummy: self.__render_html_blog(items, metadata, options, request, profile)
        defer.DeferredList(d_list).addCallback(cb)

    def __render_html_blog(self, items, metadata, options, request, profile):
        """Actually render the static blog.
        
        If mblog_data is a list of dict, we are missing the comments items so we just
        display the main items. If mblog_data is a list of couple, each couple is
        associating a main item data with the list of its comments, so we render all.

        @param items(list[tuple(dict, list)]): list of 2-tuple with
            - item(dict): item microblog data
            - comments_list(list[tuple]): list of 5-tuple with
                - service (unicode): pubsub service where the comments node is
                - node (unicode): comments node
                - failure (unicode): empty in case of success, else error message
                - comments(list[dict]): list of microblog data
                - comments_metadata(dict): metadata of the comment node
        @param metadata(dict): original node metadata
        @param options: dict defining the blog's parameters
        @param request: the HTTP request
        @param profile
        """
        if not isinstance(options, dict):
            options = {}
        user = sanitizeHtml(profile)
        root_url = '../' * len(request.postpath)
        base_url = root_url + 'blog/' + user

        def getOption(key):
            return sanitizeHtml(options[key]) if key in options else ''

        def getImageParams(key, default, alt):
            """regexp from http://answers.oreilly.com/topic/280-how-to-validate-urls-with-regular-expressions/"""
            url = options[key] if key in options else ''
            regexp = r"^(https?|ftp)://[a-z0-9-]+(\.[a-z0-9-]+)+(/[\w-]+)*/[\w-]+\.(gif|png|jpg)$"
            if re.match(regexp, url):
                url = url
            else:
                url = default
            return BlogImage(url, alt)

        avatar = os.path.normpath(root_url + getOption('avatar'))
        title = getOption(C.STATIC_BLOG_PARAM_TITLE) or user
        data = {'base_url': base_url,
                'keywords': getOption(C.STATIC_BLOG_PARAM_KEYWORDS),
                'description': getOption(C.STATIC_BLOG_PARAM_DESCRIPTION),
                'title': title,
                'favicon': avatar,
                'banner_img': getImageParams(C.STATIC_BLOG_PARAM_BANNER, avatar, title)
                }

        items.sort(key=lambda entry: (-float(entry[0].get('updated', 0))))

        data['navlinks'] = NavigationLinks(request, items, metadata, base_url)
        data['messages'] = []
        for item in items:
            item, comments_list = item
            comments, comments_count = [], 0
            for node_comments in comments_list:
                comments.extend(node_comments[3])
                try:
                    comments_count += int(node_comments[4]['rsm_count'])
                except KeyError:
                    pass
            data['messages'].append(BlogMessage(request, base_url, item, comments, comments_count))

        request.write(self.useTemplate(request, 'static_blog', data))
        request.finish()

    def render_atom_feed(self, feed, request):
        request.write(feed.encode('utf-8'))
        request.finish()

    def render_error_blog(self, error, request, profile):
        request.write(self.useTemplate(request, "static_blog_error", {'message': "Can't access requested data"}))
        request.finish()


class NavigationLinks(object):

    def __init__(self, request, items, rsm_data, base_url):
        """Build the navigation links.

        @param items (list): list of items
        @param rsm_data (dict): rsm data
        @param base_url (unicode): the base URL for this user's blog
        @return: dict
        """
        for key in ('later_message', 'later_messages', 'older_message', 'older_messages'):
            count = int(rsm_data.get('rsm_count', 0))
            setattr(self, key, '')  # key must exist when using the template
            if count <= 0 or (request.display_single == key.endswith('s')):
                continue

            index = int(rsm_data['rsm_index'])

            link_data = {'base_url': base_url, 'suffix': ''}

            if key.startswith('later_message'):
                if index <= 0:
                    continue
                link_data['item_id'] = rsm_data['rsm_first']
                link_data['post_arg'] = 'before'
            else:
                if index + len(items) >= count:
                    continue
                link_data['item_id'] = rsm_data['rsm_last']
                link_data['post_arg'] = 'after'

            if request.display_single:
                link_data['suffix'] = '&max=1'

            link = "%(base_url)s?%(post_arg)s=%(item_id)s%(suffix)s" % link_data

            setattr(self, key, BlogLink(link, key, key.replace('_', ' ')))


class BlogImage(object):

    def __init__(self, url_, alt):
        self.url = url_
        self.alt = alt


class BlogLink(object):

    def __init__(self, url_, style, text):
        self.url = url_
        self.style = style
        self.text = text


class BlogMessage(object):

    def __init__(self, request, base_url, entry, comments=None, comments_count=0):
        """

        @param request: HTTP request
        @param base_url (unicode): the base URL
        @param entry(dict): item microblog data
        @param comments(list[dict]): list of microblog data
        @param comments_count (int): total number of comments
        """
        timestamp = float(entry.get('published', 0))
        
        # FIXME: for now we assume that the comments' depth is only 1
        is_comment = not entry.get('comments', False)

        self.date = datetime.fromtimestamp(timestamp)
        self.type = "comment" if is_comment else "main_item"
        self.style = 'mblog_comment' if is_comment else ''
        self.content = self.getText(entry, 'content')

        if is_comment:
            self.author = (_("from %s") % entry['author'])
        else:
            self.author = '&nbsp;'
            self.url = (u"%s/%s" % (base_url, entry['id']))
            self.title = self.getText(entry, 'title')

            count_text = lambda count: D_('comments') if count > 1 else D_('comment')

            self.comments_text = "%s %s" % (comments_count, count_text(comments_count))

            delta = comments_count - len(comments)
            if request.display_single and delta > 0:
                prev_url = "%s?comments_max=%s" % (self.url, unicode(comments_count))
                prev_text = D_("show %(count)d previous %(comments)s") % \
                    {'count': delta, 'comments': count_text(delta)}
                self.all_comments_link = BlogLink(prev_url, "comments_link", prev_text)

        if comments:
            comments.sort(key=lambda comment: float(comment.get('published', 0)))
            self.comments = [BlogMessage(request, base_url, comment) for comment in comments]

    def getText(self, entry, key):
        if ('%s_xhtml' % key) in entry:
            return entry['%s_xhtml' % key]
        elif key in entry:
            processor = addURLToText if key.startswith('content') else sanitizeHtml
            return convertNewLinesToXHTML(processor(entry[key]))
        return None