#!/usr/bin/python2.5
"""
OpenID for the PyBlosxom comments plugin.
http://snarfed.org/space/pyblosxom+openid+comments
Copyright 2006-2007 Josh Hoyt, Kevin Turner, Ryan Barrett
Lets users use OpenID to post comments. Allows for enabling comments
for specific users only.
Requirements
============
- Steven Armstrong's compatibility plugin if not using pyblosxom 1.2+
- Steven Armstrong's session plugin (http://www.c-area.ch/code/pyblosxom/plugins/ )
- comments.py (http://pyblosxom.sf.net/blog/registry/input/comments/comments )
- Python OpenID Libraries
Installation
============
It's a good idea to get your comments working how you want them before
adding OpenID authentication. You can get documentation and code for
the comments plugin from the contrib tarball at:
https://sourceforge.net/project/showfiles.php?group_id=67445&package_id=145140
1. Put comments_openid.py in your plugin directory. Install the
JanRain Python OpenID library from openidenabled.com.
2. Make sure that py['load_plugins'] contains at least:
['comments', 'session', 'comments_openid']
3. Create a directory for putting the OpenID session data and set
py['openid_store_dir'] = '/path/to/data/directory/'
4. Add a note in your `comments-form` template that the URL input field
now supports OpenId. Something like:
If you are requiring OpenID, it's probably a good idea to get rid
of the normal comment URL field.
Configuration
=============
This plugin has several configuration options. The only required
configuration is the 'openid_store_dir'. The configuration parameters are:
- config['openid_store_dir'] ** REQUIRED
A directory for the OpenID library to use to store information
about OpenID servers and logins. This directory should be outside
of your public web space.
- config['openid_trust_root']
Trust root for the OpenID Request. Defaults to the base URL of your page.
(All URLs should fall under this directory)
- config['openid_required']
If this is set to True, do not allow comments unless OpenID
authentication succeeds. Defaults to False.
- config['openid_reject_identity']
- config['openid_reject_server']
This is a list of patterns for identities to always reject. If
this is not set, no URLs are blacklisted. The patterns follow the
form of OpenID trust roots, which are basically URLs that allow *
in the domain name to match any subdomain. For more details, see
the entry on openid.trust_root in
http://openid.net/specs.bml#mode-checkid_immediate.
- config['openid_allow_identity']
- config['openid_allow_server']
This is a list of patterns for identities to allow. If this option
is set, *only* URLs that match a pattern in this list are
allowed. If this option is not set, all URLs are whitelisted.
Changelog:
0.4
- switch to "unobtrusive" mode, with a single url field. if that url is an
openid, use openid. otherwise, don't. :P inspired by sam ruby:
http://intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID
0.3
- updated to work with janrain's python openid libraries 1.x and 2.x
- lazy-load the janrain libraries
0.2
- added nickname support using Simple Registration Extension
- moved to snarfed.org
0.1
- initial release on openidenabled.com
"""
__author__ = 'Josh Hoyt '
__author__ = 'Kevin Turner '
__author__ = 'Ryan Barrett '
__version__ = '0.4'
__url__ = 'http://snarfed.org/space/pyblosxom+openid+comments'
__description__ = 'OpenID commenting support for PyBlosxom'
# Python imports
#import cgitb; cgitb.enable() # for debugging
import time
import cgi
import traceback
from urlparse import urlparse
from Pyblosxom import tools
comment_form_fields = ['title', 'author', 'email', 'url', 'body']
comment_fields = ['title', 'pubDate', 'link', 'source', 'description']
sid_field = 'cmt_openid_sid'
class OpenIDCommentError(RuntimeError):
"""Specific exception for errors in the OpenID verification process"""
def verify_installation(request):
config = request.getConfiguration()
retval = 1
try:
import_and_initialize()
get_openid_consumer()
except:
print "Error initializing OpenID libraries."
retval = 0
try:
import comments
except ImportError:
print "Missing required plugin comments.py."
retval = 0
try:
import compatibility
except ImportError:
print "If you're not running the WSGI version of Pyblosxom"
print "you'll need the 'compatibility.py' plugin."
return retval
def import_and_initialize():
# Pyblosxom session plugin
global Session
try:
from session import Session
except ImportError, e:
logger.error('Could not find the pyblosxom session plugin in:\n' +
'\n'.join(sys.path))
raise
# OpenID imports
global trustroot
global openid
global DiscoveryFailure
global filestore
global appendArgs
try:
from openid.server import trustroot
from openid.consumer import consumer as openid
from openid.consumer.discover import DiscoveryFailure
from openid.store import filestore
from openid.oidutil import appendArgs
except ImportError, e:
logger.error('Could not find the JanRain OpenId libraries in:\n' +
'\n'.join(sys.path) + '\n\n' + e)
raise
def get_openid_consumer(request, session):
"""Initialize an OpenID store for authenticating comments.
@param request: Pyblosxom request object
@param session: session
@type session: instance of C{Session}
@return: An instance of OpenIDConsumer
@rtype: OpenID consumer store or C{None}
"""
config = request.getConfiguration()
logger = tools.getLogger()
store_dir = config.get('openid_store_dir')
if store_dir is None:
logger.error('You must define openid_store_dir in your '
'config to enable OpenID comments.')
return None
try:
store = filestore.FileOpenIDStore(store_dir)
return openid.Consumer(session, store)
except Exception:
trace = traceback.format_exception(*sys.exc_info())
logger.error('Error initializing OpenID server:\n' + '\n'.join(trace))
return None
def check_url_matches(patterns, url):
"""Check to see if C{url} matches one of C{patterns}. For a URL
to match, it must:
* Have the same protocol as the pattern (if the pattern has a protocol)
* Match the domain-name, with *.example.com matching any
subdomain of example.com
* The path of the pattern must be a prefix of the path of the URL
@param patterns: The patterns against which to match the URL
@type patterns: C{[str]}
@param url: The URL to check against the patterns
@type url: C{str}
@return: Whether any pattern in patterns matches url
@rtype: C{bool}
"""
# assign a default protocol
default_proto = urlparse(url)[0]
if not default_proto:
url = 'http://' + url
default_proto = 'http'
for pattern in patterns:
if default_proto and '://' not in pattern:
pattern = '%s://%s' % (default_proto, pattern)
if trustroot.TrustRoot.checkURL(pattern, url):
return True
return False
def start_openid_auth(request, openid_url):
form = request.getForm()
config = request.getConfiguration()
data = request.getData()
import_and_initialize()
# Try to start OpenID verification
session = Session(request)
consumer = get_openid_consumer(request, session)
if consumer is None:
return
if check_url_rejected(config, openid_url, 'identity'):
tools.getLogger().info('Rejected %r' % (openid_url,))
raise OpenIDCommentError(
'That identity is not allowed to post to this blog')
auth_req = consumer.begin(openid_url)
# Make sure that the server and identity URL are allowed by the config
server_id = auth_req.endpoint.getLocalID()
server_url = auth_req.endpoint.server_url
if (check_url_rejected(config, server_id, 'identity') or
check_url_rejected(config, server_url, 'server')):
tools.getLogger().info('Rejected %r or %r' % (server_id, server_url))
raise OpenIDCommentError(
'That identity is not allowed to post to this blog')
if 'body' not in form:
raise OpenIDCommentError('Comment body required')
if 'openid_trust_root' in config:
trust_root = config['openid_trust_root']
else:
trust_root = config['base_url']
# Save the data for the return
populate_comment_session(session, request, auth_req.assoc, trust_root)
args = {sid_field: session.id(), 'showcomments': 'yes',}
return_to = appendArgs(data['url'], args)
redirect_url = auth_req.redirectURL(trust_root, return_to)
renderer = data['renderer']
renderer.addHeader('Status', '302 Found')
renderer.addHeader("Location", redirect_url)
renderer.showHeaders()
renderer.rendered = 1
def populate_comment_session(session, request, association, trust_root):
cmt_data = {}
form = request.getForm()
for k in comment_form_fields:
if form.has_key(k):
cmt_data[k] = form[k].value
session["association"] = association
session['trust_root'] = trust_root
session["data"] = cmt_data
session.save()
def verify_return_to(return_to, trust_root, session_id):
if not trust_root or not return_to.startswith(trust_root):
return False
# Make sure that the session that we loaded actually is the
# session that went with the return_to
qs = urlparse(return_to)[4]
for k, v in cgi.parse_qsl(qs):
if k == sid_field:
return v == session_id
# No sid_field found
return False
def complete_openid_auth(request, session_id):
"""attempt to resume the session given by the session ID. Check
the OpenID server response and if it responds that this user is
who he says he is, then complete the comment posting.
@param request: Pyblosxom request object
@param session_id: The session id of the original comment post
@type session_id: str
@return: None
"""
form = request.getForm()
config = request.getConfiguration()
data = request.getData()
import_and_initialize()
session = Session(request, session_id)
consumer = get_openid_consumer(request, session)
if consumer is None:
return
query = {}
for k in form:
query[k] = form.getfirst(k)
# Get the session and the data that we need out of it in order to
# complete the request
sess = Session(request, session_id)
# Actual user-filled comment data
cmt_data = sess.get('data', {})
# Association
association = sess.get('association')
trust_root = sess.get('trust_root')
try:
if form.getfirst('openid.mode', None) == 'cancel':
raise OpenIDCommentError('OpenID authentication cancelled')
# Make sure that the return_to URL that was sent by the server has
# the same session ID as the current request
return_to = query.get('openid.return_to', '')
if (not association or
not verify_return_to(return_to, trust_root, session_id)):
raise OpenIDCommentError('Error handling OpenID response')
# Ask the OpenID library to check the server's response
response = consumer.complete(query)
if response.status == openid.SUCCESS:
if response.identity_url is None:
raise OpenIDCommentError('OpenID authentication cancelled')
else:
cmt_data['url'] = response.identity_url
# get the user's nickname using Simple Registration Extension
sreg_args = response.extensionResponse(
'http://openid.net/sreg/1.0', False)
signon_args = response.extensionResponse(
'http://openid.net/signon/1.0', False)
if 'nickname' in sreg_args:
cmt_data['author'] = sreg_args['nickname']
elif 'sreg.nickname' in signon_args:
cmt_data['author'] = signon_args['sreg.nickname']
save_comment(request, cmt_data)
for key in comment_form_fields:
key = "cmt_%s" % key
if key in data:
del data[key]
# Now that we completed the authorization transaction,
# delete the session.
sess.delete()
elif response.status == openid.FAILURE:
fmt = 'Error handling OpenID response for identity %s'
raise OpenIDCommentError(fmt % (cgi.escape(response.message),))
else:
raise OpenIDCommentError('Error handling OpenID response')
except OpenIDCommentError, why:
data['comment_message'] = why[0]
for field_name in comment_form_fields:
data['cmt_' + field_name] = cmt_data.get(field_name, '')
def save_comment(request, cmt_data):
"""Call the comments plugin to finish handling the comment data.
This code is mostly copied from comments.cb_prepare, but it was
not exposed in the way it needed to be in order to re-use it.
@param request: The current request
@type request: Pyblosxom request object
@param cmt_data: The comment data
@type cmt_data: {str:str}
"""
from comments import add_dont_follow, massage_link, sanitize, writeComment
config = request.getConfiguration()
enc = config['blog_encoding']
for k, v in cmt_data.iteritems():
if type(v) is type(''):
cmt_data[k] = v.decode(enc)
openid_url = cmt_data['url']
body = add_dont_follow(sanitize(cmt_data['body']), config)
cmt_time = time.time()
w3cdate = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(cmt_time))
date = time.strftime('%a %d %b %Y', time.gmtime(cmt_time))
cdict = {'title': cmt_data['title'],
'pubDate': str(cmt_time),
'w3cdate': w3cdate,
'cmt_date': date,
'link': massage_link(cmt_data.get('url', openid_url)),
'source': '',
'email': cmt_data.get('email', ''),
'description': body,
'author': cmt_data.get('author', openid_url),
'openid_url': openid_url,
}
data = request.getData()
data["comment_message"] = writeComment(request, config, data, cdict, enc)
def check_url_rejected(config, url, suffix):
"""Return whether URL is allowed by the config. This uses
C{config['openid_allow_' + suffix]} and C{config['openid_reject_'
+ suffix]} to determine if C{url} is allowed to comment.
@param config: Pyblosxom configuration dictionary
@type config: C{dict}
@param url: The URL to check
@type url: C{str}
@param suffix: Which variables in the config to use
@type suffix: C{str}
@return: Whether to reject comments made with this URL
@rtype: C{bool}
"""
allow_only = config.get('openid_allow_' + suffix, None)
if (allow_only is not None and
not check_url_matches(allow_only, url)):
# Did not match allow_only, so reject
return True
reject_always = config.get('openid_reject_' + suffix, None)
if (reject_always is not None and
check_url_matches(reject_always, url)):
# Matched reject_always, so reject
return True
# Survived allow_only and reject_always
return False
#******************************
# Callbacks
#******************************
def cb_prepare(args):
"""
Handle comment-related posts, enabling OpenID
@param request: pyblosxom request object
@type request: a Pyblosxom request object
"""
request = args['request']
data = request.getData()
form = request.getForm()
config = request.getConfiguration()
if not config.get('openid_enabled', True) or not form:
return
elif 'comments_openid_prepared' in data:
return
data['comments_openid_prepared'] = True
# Session id of None generates a new session
session_id = form.getfirst(sid_field, None)
if session_id is not None:
msg = complete_openid_auth(request, session_id)
elif 'preview' not in form and 'url' in form:
try:
openid_url = form.getfirst('url')
try:
start_openid_auth(request, openid_url)
except DiscoveryFailure:
fmt = "Unable to use %s as an OpenID identity URL"
msg = fmt % (cgi.escape(openid_url),)
if config.get('openid_required', False):
raise OpenIDCommentError(msg)
else:
# proceed without OpenID
data['comments_not_openid'] = True
except OpenIDCommentError, why:
data['comment_message'] = why[0]
for field_name in comment_form_fields:
data['cmt_' + field_name] = form.getfirst(field_name, '')
else:
data['comments_not_openid'] = True
def cb_comment_reject(args):
"""Reject comments that were not generated by this plugin, as a
way of disabling the comments plugin's native form handlers.
@param request: pyblosxom request object
@type request: a Pyblosxom request object
@param comment: the data that will be saved if we do not reject it
@type request: {str:str}
"""
comment = args['comment']
request = args['request']
data = request.getData()
config = request.getConfiguration()
cb_prepare(args)
# Do not reject any comments if OpenID is disabled
if not config.get('openid_enabled', True):
return False
elif 'comments_not_openid' in data:
return config.get('openid_required', False)
form = request.getForm()
session_id = form.getfirst(sid_field, None)
if (form.getfirst('openid.mode', None) in (None, 'cancel')):
return True
if session_id or 'openid_url' in comment:
# Allow a comment if the auth_method is OpenID and a URL is set
openid_url = comment.get('openid_url')
if openid_url:
return check_url_rejected(config, openid_url, 'identity')
return True