"""Tumblr + Disqus blog webmention implementation.
To use, go to your Tumblr dashboard, click Customize, Edit HTML, then put this
in the head section::
<link rel="webmention" href="https://brid.gy/webmention/tumblr">
Misc notes and background:
* http://disqus.com/api/docs/
* http://disqus.com/api/docs/posts/create/
* http://help.disqus.com/customer/portal/articles/466253-what-html-tags-are-allowed-within-comments-
Guest post (w/arbitrary author, url):
* http://spirytoos.blogspot.com/2013/12/not-so-easy-posting-as-guest-via-disqus.html
* http://stackoverflow.com/questions/15416688/disqus-api-create-comment-as-guest
* http://jonathonhill.net/2013-07-11/disqus-guest-posting-via-api/
Can send url and not look up disqus thread id!
* http://stackoverflow.com/questions/4549282/disqus-api-adding-comment
* https://disqus.com/api/docs/forums/listThreads/
Test command line::
curl localhost:8080/webmention/tumblr -d 'source=http://localhost/response.html&target=http://snarfed.tumblr.com/post/60428995188/glen-canyon-http-t-co-fzc4ehiydp?foo=bar#baz'
"""
import collections
import logging
import re
import urllib.parse
from flask import request
from google.cloud import ndb
from oauth_dropins import tumblr as oauth_tumblr
from webutil.flask_util import flash
from webutil.util import json_dumps, json_loads
from werkzeug.exceptions import BadRequest
from flask_app import app
import models
import superfeedr
import util
logger = logging.getLogger(__name__)
TUMBLR_AVATAR_URL = 'http://api.tumblr.com/v2/blog/%s/avatar/512'
DISQUS_API_CREATE_POST_URL = 'https://disqus.com/api/3.0/posts/create.json'
DISQUS_API_THREAD_DETAILS_URL = 'http://disqus.com/api/3.0/threads/details.json'
DISQUS_ACCESS_TOKEN = util.read('disqus_access_token')
DISQUS_API_KEY = util.read('disqus_api_key')
DISQUS_API_SECRET = util.read('disqus_api_secret')
# Tumblr has no single standard markup or JS for integrating Disqus. It does
# have a default way, but themes often do it themselves, differently. Sigh.
# Details in https://github.com/snarfed/bridgy/issues/278
DISQUS_SHORTNAME_RES = (
re.compile(r"""
(?:https?://disqus\.com/forums|disqus[ -_]?(?:user|short)?name)
\ *[=:/]\ *['"]?
([^/"\' ]+) # the actual shortname
""", re.IGNORECASE | re.VERBOSE),
re.compile(r'https?://([^./"\' ]+)\.disqus\.com/embed\.js'),
)
[docs]
class Tumblr(models.Source):
"""A Tumblr blog.
The key name is the blog domain.
"""
GR_CLASS = collections.namedtuple('FakeGrClass', ('NAME',))(NAME='Tumblr')
OAUTH_START = oauth_tumblr.Start
SHORT_NAME = 'tumblr'
disqus_shortname = ndb.StringProperty()
def feed_url(self):
# http://www.tumblr.com/help (search for feed)
return urllib.parse.urljoin(self.silo_url(), '/rss')
def silo_url(self):
return self.domain_urls[0]
def edit_template_url(self):
return f'http://www.tumblr.com/customize/{self.auth_entity.id()}'
[docs]
@staticmethod
def new(auth_entity=None, blog_name=None, **kwargs):
"""Creates and returns a :class:`Tumblr` for the logged in user.
Args:
auth_entity (oauth_dropins.tumblr.TumblrAuth):
blog_name (str): which blog, optional, passed to :meth:`urls_and_domains`
"""
urls, domains = Tumblr.urls_and_domains(auth_entity, blog_name=blog_name)
if not urls or not domains:
flash('Tumblr blog not found. Please create one first!')
return None
id = domains[0]
return Tumblr(id=id,
auth_entity=auth_entity.key,
domains=domains,
domain_urls=urls,
name=auth_entity.user_display_name(),
picture=TUMBLR_AVATAR_URL % id,
superfeedr_secret=util.generate_secret(),
**kwargs)
[docs]
@staticmethod
def urls_and_domains(auth_entity, blog_name=None):
"""Returns this blog's URL and domain.
Args:
auth_entity (oauth_dropins.tumblr.TumblrAuth)
blog_name (str): which blog, optional, matches the ``name`` field for one
of the blogs in ``auth_entity.user_json['user']['blogs']``
Returns:
([str url], [str domain]):
"""
for blog in json_loads(auth_entity.user_json).get('user', {}).get('blogs', []):
if ((blog_name and blog_name == blog.get('name')) or
(not blog_name and blog.get('primary'))):
return [blog['url']], [util.domain_from_link(blog['url']).lower()]
return [], []
[docs]
def verified(self):
"""Returns True if we've found the webmention endpoint and Disqus."""
return self.webmention_endpoint and self.disqus_shortname
[docs]
def verify(self):
"""Checks that Disqus is installed as well as the webmention endpoint.
Stores the result in webmention_endpoint.
"""
if self.verified():
return
super().verify(force=True)
html = getattr(self, '_fetched_html', None) # set by Source.verify()
if not self.disqus_shortname and html:
self.discover_disqus_shortname(html)
def discover_disqus_shortname(self, html):
# scrape the disqus shortname out of the page
logger.info("Looking for Disqus shortname in fetched HTML")
for regex in DISQUS_SHORTNAME_RES:
match = regex.search(html)
if match:
self.disqus_shortname = match.group(1)
logger.info(f"Found Disqus shortname {self.disqus_shortname}")
self.put()
class ChooseBlog(oauth_tumblr.Callback):
def finish(self, auth_entity, state=None):
if not auth_entity:
util.maybe_add_or_delete_source(Tumblr, auth_entity, state)
return
vars = {
'action': '/tumblr/add',
'state': state,
'auth_entity_key': auth_entity.key.urlsafe().decode(),
'blogs': [{'id': b['name'],
'title': b.get('title', ''),
'domain': util.domain_from_link(b['url'])}
# user_json is the user/info response:
# http://www.tumblr.com/docs/en/api/v2#user-methods
for b in json_loads(auth_entity.user_json)['user']['blogs']
if b.get('name') and b.get('url')],
}
logger.info(f'Rendering choose_blog.html with {vars}')
return util.render_template('choose_blog.html', **vars)
@app.route('/tumblr/add', methods=['POST'])
def tumblr_add():
util.maybe_add_or_delete_source(
Tumblr,
ndb.Key(urlsafe=request.form['auth_entity_key']).get(),
request.form['state'],
blog_name=request.form['blog'],
)
class SuperfeedrNotify(superfeedr.Notify):
SOURCE_CLS = Tumblr
# Tumblr doesn't seem to use scope
# http://www.tumblr.com/docs/en/api/v2#oauth
start = util.oauth_starter(oauth_tumblr.Start).as_view(
'tumblr_start', '/tumblr/choose_blog')
app.add_url_rule('/tumblr/start', view_func=start, methods=['POST'])
app.add_url_rule('/tumblr/choose_blog', view_func=ChooseBlog.as_view(
'tumblr_choose_blog', 'unused'))
app.add_url_rule('/tumblr/delete/finish', view_func=oauth_tumblr.Callback.as_view(
'tumblr_delete_finish', '/delete/finish'))
app.add_url_rule('/tumblr/notify/<id>', view_func=SuperfeedrNotify.as_view('tumblr_notify'), methods=['POST'])