Browse files

Revise comments to support user-supplied names and URLs

Standard blog comments allow you to not just give your actual comment
but also tell people your name and often a website URL. DWiki has
allowed this implicitly (you could manually add some sort of a signature
if you wanted to and some people do), but there is a lot of merit in
supporting it explicitly in the way that people expect and recognize.
Also, explicit identification allows DWiki to show it instead of the
submission IP in various 'who wrote this' bits.

- new comment format in model.py / model_comment.py
- new quoting function in httputil.py
- actual support for all of this in comments.py, with related changes
  and cleanups in atomgen.py and macros.py
- the comment-related global variables have changed, so document that
- templates changed to actually show this information where appropriate

DWiki tries hard to reject usernames and urls with dangerous things
(especially newlines, which would break the storage format) and
correctly quote what remains but I may find that it is not sufficient.

(It does not attempt to sanitize things exactly; if there is bad stuff
it simply nulls the field. Better safe than sorry.)
  • Loading branch information...
1 parent 5d4d23e commit e750c0314c2c906ff1b0241dbf7c43ae56be3264 @siebenmann committed Aug 30, 2013
View
4 atomgen.py
@@ -393,10 +393,8 @@ def atomcomments(context):
for ts, path, cname, c in _fillcomments(context):
np = context.model.get_page(path)
nc = context.clone_to_page(np)
- nc.setvar(comments.com_stash_var, c)
+ comments.set_com_vars(nc, c)
nc.setvar(":comment:name", cname)
- nc.setvar("comment-ip", c.ip)
- nc.setvar("comment-user", c.user)
t = template.Template(to).render(nc)
sz += len(t)
res.append(t)
View
99 comments.py
@@ -290,12 +290,19 @@ def commentpre(context):
# text (one line per iteration). If this is desired, we ought to do
# it explicitly, not implicitly.
post_bit = """<input type=submit name=post value="Post Comment">"""
-fpreview_bit="""<input type=submit name=dopref value="Visionner votre commentaire">"""
comment_form = """<form method=post action="%s">
<textarea rows='15' cols='75' name='comment'>
%s</textarea> <br>
<span style="display: none;">Please do not enter anything here:
<input name=name size=30> </span>
+<table>
+<tr> <td style="padding-right: 10px">
+ <label for="whois">Who are you?</label> </td>
+ <td> <input name=whois size=40 type="text" value="%s"> </td> </tr>
+<tr> <td style="padding-right: 10px">
+ <label for="whourl">(optional URL)</label> </td>
+ <td> <input name=whourl size=40 type="text" value="%s"> </td> </tr>
+</table>
<input type=hidden name=previp value="%s">
<input type=submit value="Preview Comment">
%s
@@ -323,7 +330,6 @@ def commentform(context):
post = ''
else:
comdata = ''
- #post = fpreview_bit
post = ''
curl = context.url(context.page, "writecomment")
#previp = remote_ip_prefix(context)
@@ -333,7 +339,12 @@ def commentform(context):
previp = gen_ip_prefix(context)
else:
previp = 'omitted'
- data = comment_form % (curl, comdata, previp, post)
+ # TODO: is quotehtml() the right quoting to apply for value="..."
+ # bits? It's not clear to me. I need to read more specifications.
+ data = comment_form % (curl, comdata,
+ httputil.quotehtml(context.getviewvar("whois")),
+ httputil.quotehtml(context.getviewvar("whourl")),
+ previp, post)
context.unrel_time()
return data
htmlrends.register("comment::form", commentform)
@@ -348,6 +359,8 @@ def post(context, resp):
comdata = context.getviewvar("comment")
if comdata is None:
return False
+ whois = context.getviewvar("whois")
+ whourl = context.getviewvar("whourl")
# We immediately disallow empty comments.
#comdata = comtrim(comdata)
@@ -372,7 +385,8 @@ def post(context, resp):
# post_comment() does permissions checking itself,
# and the caller has already done it too, so we don't
# do it a *third* time; we just go straight.
- res = context.model.post_comment(comdata, context)
+ res = context.model.post_comment(comdata, context,
+ whois, whourl)
if res:
context.setvar(":comment:post", "good")
else:
@@ -430,6 +444,15 @@ def atomcountlink(context):
return httputil.quotehtml(_gencountlink(context, True))
htmlrends.register("comment::atomlink", atomcountlink)
+def set_com_vars(context, c):
+ context.setvar(com_stash_var, c)
+ context.setvar("comment-ip", c.ip)
+ context.setvar("comment-name", c.username)
+ context.setvar("comment-url", c.userurl)
+ # comment-login exists only if it is not the guest user.
+ if not c.is_anon(context):
+ context.setvar("comment-login", c.user)
+
# Display comments in a blogdir style thing.
# Unlike blogdir, comments go in oldest-to-newest order.
com_stash_var = ":comment:comment"
@@ -461,9 +484,8 @@ def showcomments(context):
for c in coms:
nc = context.clone()
context.newtime(c.time)
- nc.setvar(com_stash_var, c)
- nc.setvar("comment-ip", c.ip)
- nc.setvar("comment-user", c.user)
+ set_com_vars(nc, c)
+
res.append(template.Template(to).render(nc))
context.newtime(nc.modtime)
return ''.join(res)
@@ -489,6 +511,7 @@ def comdate(context):
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(c.time))
htmlrends.register("comment::date", comdate)
+# TODO: remove this soon. It's now transition support only.
# We don't show the user if it's the guest user.
# This is a design decision on my part.
def comuser(context):
@@ -503,6 +526,37 @@ def comuser(context):
return c.user
htmlrends.register("comment::user", comuser)
+# Display the authorship information of a comment.
+# Bugs: this is too complicated. But doing it by templates is
+# frankly insane; there would be too many of them. I may change
+# my mind later.
+def com_optlink(txt, c):
+ if not c.userurl:
+ return httputil.quotehtml(txt)
+ return htmlrends.makelink(txt, httputil.quoteurl(c.userurl))
+def comauthor(context):
+ """Display the author information for a comment, drawing on
+ the given name, website URL, DWiki login, and comment IP address
+ as necessary and available. Only works inside comment::showall.
+ This potentially generates HTML, not just plain text."""
+ if com_stash_var not in context:
+ return ''
+ c = context[com_stash_var]
+ ares = []
+ if c.username:
+ ares.append("By")
+ ares.append(com_optlink(c.username, c))
+ if not c.is_anon(context):
+ ares.append("(%s)" % c.user)
+ elif not c.is_anon(context):
+ ares.append("By")
+ ares.append(com_optlink(c.user, c))
+ else:
+ ares.append("From")
+ ares.append(com_optlink(c.ip, c))
+ return ' '.join(ares)
+htmlrends.register("comment::author", comauthor)
+
# In the name of short anchor names, we hope that user + timestamp never
# collides. Using the hash name is ... the ugly.
def anchor_for(c):
@@ -537,6 +591,32 @@ def comtrim(comdata):
else:
return comdata + "\n"
+# Sanitize the name fields (whois and whourl).
+# These cannot include embedded newlines. If they do, the field is
+# nulled out.
+def name_sanitize(context, vvar):
+ fld = context.getviewvar(vvar)
+ if not fld:
+ fld = ''
+ fld = fld.strip()
+ if '\r' in fld or '\n' in fld:
+ fld = ''
+ context.setviewvar(vvar, fld)
+# url_sanitize requires the URL to start with http:// or https://.
+# It may insist on more sanitization later.
+safeurl_re = re.compile("^[a-z0-9._-]+(/[-a-z0-9._/]+)?$")
+def url_sanitize(context, vvar):
+ name_sanitize(context, vvar)
+ lfd = context.getviewvar(vvar).lower()
+ if lfd.startswith("http://") or lfd.startswith("https://"):
+ pass
+ elif safeurl_re.match(lfd):
+ # TODO: this is dangerous, even though we are conservative
+ # in schema-less URLs that we will accept.
+ context.setviewvar(vvar, "http://" + context.getviewvar(vvar))
+ else:
+ context.setviewvar(vvar, '')
+
#
# We want people who write comments to be able to immediately see their
# comment even in the face of the BFC or especially of the IMC (because
@@ -585,6 +665,8 @@ def render(self):
# code smell.)
comdata = comtrim(self.context.getviewvar("comment"))
self.context.setviewvar("comment", comdata)
+ name_sanitize(self.context, "whois")
+ url_sanitize(self.context, "whourl")
# 'post' is the name of the 'Post Comment' button,
# which is set when we are posting (but not when
@@ -615,4 +697,5 @@ def render(self):
views.register('showcomments', views.TemplateView)
views.register('writecomment', WriteCommentView, canPOST = True,
- postParams = ('comment', 'previp', 'post', 'dopref', 'name'))
+ postParams = ('comment', 'previp', 'post', 'dopref', 'name',
+ 'whois', 'whourl', ))
View
7 httputil.py
@@ -222,3 +222,10 @@ def quotehtml(hstr):
for qe, qs in quoteEntities:
hstr = hstr.replace(qe, qs)
return hstr
+
+# This is not quite equal to what wikirend does. Different contexts.
+uquoteEntities = (('"', '%22'), (' ', '%20'), ('>', '%3E'))
+def quoteurl(ustr):
+ for qe, qs in uquoteEntities:
+ ustr = ustr.replace(qe, qs)
+ return ustr
View
6 macros.py
@@ -612,8 +612,12 @@ def _lif((ts, ppath, cname)):
if not c:
return
rend.addPiece('<a href="%s">' % url)
- if c.user != rend.ctx.default_user():
+ if c.username and not c.is_anon(rend.ctx):
+ rend.text("%s (%s)" % (c.username, c.user), "none")
+ elif not c.is_anon(rend.ctx):
rend.text(c.user, "none")
+ elif c.username:
+ rend.text(c.username, "none")
else:
rend.text(c.ip, "none")
View
53 model.py
@@ -1,13 +1,13 @@
#
# The model component of our pseudo-MVC application.
#
-import re
-
import utils
import pages
import derrors, storage
+import model_comment
+
class NoPage:
type = "bad"
def exists(self):
@@ -29,44 +29,6 @@ def __init__(self, user, pwhash, groups):
self.pwhash = pwhash
self.groups = groups
-
-# Comments are stored in a packed form.
-# (I am almost tempted to pickle them, but I would rather have things
-# be readable in raw text.)
-commentbody_re = re.compile("USER ([^\n]+)\nIP ([^\n]+)\n(.*)$",
- re.DOTALL)
-class Comment:
- def __init__(self, context = None, data = None):
- if context:
- self.user = context.login
- self.ip = context["remote-ip"]
- else:
- self.user = None
- self.ip = None
- if not data:
- self.data = ''
- else:
- self.data = data
- self.time = None
- self.name = None
- def __str__(self):
- if not self.data:
- return ''
- return 'USER %s\nIP %s\n%s' % (self.user, self.ip, self.data)
- def fromstore(self, fileobj, name):
- blob = fileobj.contents()
- if not blob:
- return False
- mo = commentbody_re.match(blob)
- if not mo:
- return False
- self.user = mo.group(1)
- self.ip = mo.group(2)
- self.data = mo.group(3)
- self.time = fileobj.timestamp()
- self.name = name
- return True
-
# Return a dict of User objects read from the password file.
def load_pwfile(cfg):
if "authfile" not in cfg:
@@ -291,11 +253,12 @@ def _commentpage(self, path):
# Return True or False depending on whether the comment posted
# or not.
- def post_comment(self, comdata, context):
+ def post_comment(self, comdata, context, username, userurl):
if not self.cstore or \
not context.page.comment_ok(context):
return False
- nc = Comment(context, comdata)
+ nc = model_comment.CommentV1()
+ nc.fromform(context, comdata, username, userurl)
# FIXME: trap errors somehow. For now commentstore
# failures are truly fatal. (The complication is
# logging them somehow.)
@@ -332,9 +295,9 @@ def get_comment(self, page, comment):
po = self._commentpage(compath)
if not po or po.type != "file" or not po.displayable():
raise derrors.IntErr, "missing or undisplayable comment '%s' on page '%s'" % (comment, page)
- c = Comment()
- if not c.fromstore(po, comment):
- raise derrors.IntErr, "misformatted comment '%s' on '%s'" % (comment, page)
+ c = model_comment.loadcomment(po, comment)
+ if c is None:
+ raise derrors.IntErr, "misformatted comment '%s' on '%s'" % (comment, page.path)
return c
# For complicated reasons there is no real point in virtualizing
View
150 model_comment.py
@@ -0,0 +1,150 @@
+#
+# The specific model classes and functions for comments.
+# This is separate from model.py because it is getting big.
+
+import re
+
+import utils
+import pages
+
+import derrors, storage
+
+# ---
+# Original (legacy) comment format, which has only DWiki login and
+# comment IP address.
+
+# Comments are stored in a packed form.
+commentbody_re = re.compile("USER ([^\n]+)\nIP ([^\n]+)\n(.*)$",
+ re.DOTALL)
+class Comment:
+ def __init__(self, context = None, data = None):
+ if context:
+ self.user = context.login
+ self.ip = context["remote-ip"]
+ else:
+ self.user = None
+ self.ip = None
+ if not data:
+ self.data = ''
+ else:
+ self.data = data
+ self.time = None
+ self.name = None
+ self.username = ''
+ self.userurl = ''
+ self.anon = None
+ def __str__(self):
+ if not self.data:
+ return ''
+ return 'USER %s\nIP %s\n%s' % (self.user, self.ip, self.data)
+ def fromstore(self, fileobj, name):
+ blob = fileobj.contents()
+ if not blob:
+ return False
+ mo = commentbody_re.match(blob)
+ if not mo:
+ return False
+ self.user = mo.group(1)
+ self.ip = mo.group(2)
+ self.data = mo.group(3)
+ self.time = fileobj.timestamp()
+ self.name = name
+ return True
+
+ def is_anon(self, context):
+ return self.user == context.default_user()
+
+# ---
+# New format comments now include an explicit version field as the first
+# field.
+commentver_re = re.compile("^VER (\d+)\n")
+
+# V1 new comment format. This adds a user-supplied name and URL so that
+# DWiki acts more like traditional blog comments and while I'm at it,
+# a marker of whether the DWiki login was the default/anonymous/guest
+# user at the time of comment submission (... in case you remove or
+# change it later or something).
+#
+commentv1_re = re.compile("^VER 1\nUSER ([^\n]+)\nANON (Yes|No)\nNAME ([^\n]*)\nURL ([^\n]*)\nIP ([^\n]+)\n(.*)$",
+ re.DOTALL)
+class CommentV1:
+ def __init__(self):
+ self.user = None
+ self.anon = None
+ self.ip = None
+ self.data = ''
+ self.username = ''
+ self.userurl = ''
+ self.time = None
+ self.name = None
+
+ def __str__(self):
+ if not self.data:
+ return ''
+ return "VER 1\nUSER %s\nANON %s\nNAME %s\nURL %s\nIP %s\n%s" % \
+ (self.user, self.anon,
+ self.username, self.userurl, self.ip,
+ self.data)
+ # Called only if the blob is non-null and asserts to be a V1 format.
+ def fromstore(self, fileobj, name):
+ blob = fileobj.contents()
+ if not blob:
+ raise derrors.IntErr("CommentV1 fromstore blob is empty")
+ mo = commentv1_re.match(blob)
+ if not mo:
+ return False
+ self.user = mo.group(1)
+ self.anon = mo.group(2)
+ self.username = mo.group(3).strip()
+ self.userurl = mo.group(4).strip()
+ self.ip = mo.group(5)
+ self.data = mo.group(6)
+ self.time = fileobj.timestamp()
+ self.name = name
+ return True
+
+ def fromform(self, context, data, username, userurl):
+ self.user = context.login
+ self.ip = context["remote-ip"]
+ self.data = data
+ self.username = username
+ self.userurl = userurl
+ if context.is_login_default():
+ self.anon = "Yes"
+ else:
+ self.anon = "No"
+
+ def is_anon(self, _):
+ return self.anon == "Yes"
+
+# ----
+# This loads comments in any known format from the disk, working out
+# which format the comment is in itself. Returns the comment or None
+# and may raise derrors.IntErr in some situations.
+
+def loadcomment(fileobj, name):
+ blob = fileobj.contents()
+ if not blob:
+ return None
+ mo = commentver_re.match(blob)
+ # might be a version zero comment.
+ if not mo:
+ mo = commentbody_re.match(blob)
+ if mo:
+ c = Comment()
+ else:
+ return None
+ elif mo.group(1) == "1":
+ c = CommentV1()
+ else:
+ raise derrors.IntErr("Uknown comment format version: '%s' in %s" %
+ (mo.group(1), name))
+
+ # Load:
+ if c.fromstore(fileobj, name):
+ return c
+ else:
+ return None
+
+# TODO: should we have a createcomment() function? Probably not, since the
+# arguments are likely to keep evolving.
View
13 test.timestamps
@@ -52,7 +52,6 @@
@1117230477 test/comments/Tests/CommentPage/01620dd5ff121fe241853281d28bed2fbe458e18
@1117234568 test/comments/Tests/CommentPage/d7ec1f3af93cff819a320d30c1ef57d4b67d4580
@1117234738 test/pages/Tests/CommentPage
-@1117234884 test/templates/comment/user
@1117234888 test/templates/comment/ip
@1117236070 test/pages/dwiki/WhyNotWebEditing
@1117237135 test/pages/dwiki/NewFeatures/Comments
@@ -102,7 +101,6 @@
@1117739186 test/pages/dwiki/NewFeatures/SpaceLinkSeps
@1117739568 test/pages/dwiki/NewFeatures/ImprovedLinkAbbrevs
@1117838236 test/pages/dwiki/NewFeatures/NestedListsWithIndent
-@1117940219 test/templates/comment/header.tmpl
@1117940229 test/templates/comment/comment.tmpl
@1117943328 test/templates/syndication/feeds.tmpl
@1117947495 test/pages/dwiki/NewFeatures/AtomFeeds
@@ -198,7 +196,6 @@
@1141530205 test/templates/dwiki/view-atom.tmpl
@1141530211 test/templates/dwiki/view-atomcomments.tmpl
@1141540244 test/templates/syndication/category
-@1141542964 test/templates/syndication/atomcomment.tmpl
@1143013960 test/pages/dwiki/NewFeatures/Caching
@1143084648 test/pages/Tests/SlimTableTest
@1143325875 test/templates/struct/readme-blog.tmpl
@@ -229,7 +226,6 @@
@1187294113 test/templates/dwiki/view-sitemap.tmpl
@1197566359 test/pages/Tests/CommentPageII
@1197566376 test/templates/comment/writebody.tmpl
-@1197566572 test/templates/comment/writebody-anon.tmpl
@1197584790 test/pages/drafts/FoobarTest
@1237418066 test/pages/Tests/LiteralTest
@1306936630 test/pages/help/DWikiText
@@ -255,7 +251,6 @@
@1366649809 test/templates/struct/prev-full.tmpl
@1366649817 test/templates/struct/next-full.tmpl
@1375484574 test/templates/struct/datecrumbs-full.tmpl
-@1375995049 test/pages/dwiki/GlobalVariables
@1376004071 test/templates/comment/needauth.tmpl
@1376004212 test/templates/needauth.tmpl
@1376004245 test/templates/errors/badaccess.tmpl
@@ -270,3 +265,11 @@
@1377545233 test/templates/syndication/rss2entry.tmpl
@1377613022 test/pages/dwiki/TemplatesUsed
@1377613973 test/pages/dwiki/NewFeatures/RSS2Feeds
+@1377804684 test/templates/comment/user
+@1377804779 test/templates/comment/author
+@1377805124 test/templates/comment/authorship
+@1377805156 test/templates/comment/header.tmpl
+@1377809260 test/pages/dwiki/GlobalVariables
+@1377809842 test/templates/syndication/atomcomment-url
+@1377863849 test/templates/syndication/atomcomment.tmpl
+@1377872682 test/templates/comment/writebody-anon.tmpl
View
7 test/pages/dwiki/GlobalVariables
@@ -38,7 +38,12 @@ additional global variables:
_:wikitext:title:nohtml_ template renderer.
| _login_ | The currently authenticated user.
| _comment-ip_ | IP address that posted the current comment.
-| _comment-user_ | User that posted the current comment.
+| _comment-login_ | Login of the user that posted the current
+ comment, if it is not the anonymous user.
+| _comment-name_ | The supplied name of the user that posted the
+ current comment, if any.
+| _comment-url_ | The user's supplied website URL (if any) for
+ the current comment.
| _:comment:post_ | The result of an attempt to post a comment.
One of 'good', 'bad', 'badchar', or 'nocomment'
(the latter if it was an attempt to post an
View
1 test/templates/comment/author
@@ -0,0 +1 @@
+By ${!comment-name}
View
1 test/templates/comment/authorship
@@ -0,0 +1 @@
+%{comment::author}
View
2 test/templates/comment/header.tmpl
@@ -1 +1 @@
-#{|comment/user|comment/ip} at @{comment::date}
+#{|comment/authorship|comment/user|comment/ip} at @{comment::date}
View
2 test/templates/comment/user
@@ -1 +1 @@
-By %{comment::user}
+By ${!comment-login}
View
5 test/templates/comment/writebody-anon.tmpl
@@ -1,3 +1,2 @@
-%{cond::anonymous}<p> Please remember to sign your comment; otherwise,
-the only author identification will be your IP address. Your IP address
-will be shown with your posted comment. </p>
+%{cond::anonymous}<p> Please note that your IP address will be shown with
+your comment if you don't otherwise identify yourself. </p>
View
1 test/templates/syndication/atomcomment-url
@@ -0,0 +1 @@
+<uri>${!comment-url}</uri>
View
4 test/templates/syndication/atomcomment.tmpl
@@ -1,8 +1,8 @@
<entry>
-<title type="html">#{|comment/user|comment/ip} on /${page}</title>
+<title type="html">#{|comment/author|comment/user|comment/ip} on /${page}</title>
<id>tag:${wikiname}:${page}:${:comment:name}</id>
<link rel="alternate" type="text/html" href="@{atom::commenturl}" />
-<author><name>#{|comment/user|comment/ip}</name></author>
+<author><name>#{|comment/author|comment/user|comment/ip}</name>#{syndication/atomcomment-url}</author>
<content type="html">%{atom::comment}</content>
<updated>@{atom::commentstamp}</updated>
</entry>

0 comments on commit e750c03

Please sign in to comment.