You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
isso/isso/views/comments.py

1242 lines
44 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import collections
import re
import time
import functools
import json # json.dumps to put URL in <script>
from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET
from werkzeug.http import dump_cookie
from werkzeug.wsgi import get_current_url
from werkzeug.utils import redirect
from werkzeug.routing import Rule
from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str
from isso import utils, local
from isso.utils import (http, parse,
JSONResponse as JSON, XMLResponse as XML,
render_template)
from isso.views import requires
from isso.utils.hash import sha1
from isso.utils.hash import md5
try:
from cgi import escape
except ImportError:
from html import escape
try:
from urlparse import urlparse
except ImportError:
from urllib.parse import urlparse
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
try:
from StringIO import StringIO
except ImportError:
from io import BytesIO as StringIO
# from Django appearently, looks good to me *duck*
__url_re = re.compile(
r'^'
r'(https?://)?'
# domain...
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)'
r'$', re.IGNORECASE)
def isurl(text):
return __url_re.match(text) is not None
def normalize(url):
if not url.startswith(("http://", "https://")):
return "http://" + url
return url
def xhr(func):
"""A decorator to check for CSRF on POST/PUT/DELETE using a <form>
element and JS to execute automatically (see #40 for a proof-of-concept).
When an attacker uses a <form> to downvote a comment, the browser *should*
add a `Content-Type: ...` header with three possible values:
* application/x-www-form-urlencoded
* multipart/form-data
* text/plain
If the header is not sent or requests `application/json`, the request is
not forged (XHR is restricted by CORS separately).
"""
"""
@apiDefine csrf
@apiHeader {string="application/json"} Content-Type
The content type must be set to `application/json` to prevent CSRF attacks.
"""
def dec(self, env, req, *args, **kwargs):
if req.content_type and not req.content_type.startswith("application/json"):
raise Forbidden("CSRF")
return func(self, env, req, *args, **kwargs)
return dec
class API(object):
FIELDS = set(['id', 'parent', 'text', 'author', 'website',
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification'])
# comment fields, that can be submitted
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification'])
VIEWS = [
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('latest', ('GET', '/latest')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')),
('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
]
def __init__(self, isso, hasher):
self.isso = isso
self.hash = hasher.uhash
self.cache = isso.cache
self.signal = isso.signal
self.conf = isso.conf.section("general")
self.moderated = isso.conf.getboolean("moderation", "enabled")
# this is similar to the wordpress setting "Comment author must have a previously approved comment"
self.approve_if_email_previously_approved = isso.conf.getboolean("moderation", "approve-if-email-previously-approved")
self.trusted_proxies = list(isso.conf.getiter("server", "trusted-proxies"))
self.guard = isso.db.guard
self.threads = isso.db.threads
self.comments = isso.db.comments
for (view, (method, path)) in self.VIEWS:
isso.urls.add(
Rule(path, methods=[method], endpoint=getattr(self, view)))
@classmethod
def verify(cls, comment):
if "text" not in comment:
return False, "text is missing"
if not isinstance(comment.get("parent"), (int, type(None))):
return False, "parent must be an integer or null"
for key in ("text", "author", "website", "email"):
if not isinstance(comment.get(key), (str, type(None))):
return False, "%s must be a string or null" % key
if len(comment["text"].rstrip()) < 3:
return False, "text is too short (minimum length: 3)"
if len(comment["text"]) > 65535:
return False, "text is too long (maximum length: 65535)"
if len(comment.get("email") or "") > 254:
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
if comment.get("website"):
if len(comment["website"]) > 254:
return False, "arbitrary length limit"
if not isurl(comment["website"]):
return False, "Website not Django-conform"
return True, ""
# Common definitions for apidoc follow:
"""
@apiDefine plainParam
@apiParam {number=0,1} [plain]
Iff set to `1`, the plain text entered by the user will be returned in the comments `text` attribute (instead of the rendered markdown).
"""
"""
@apiDefine commentResponse
@apiSuccess {number} id
The comments id (assigned by the server).
@apiSuccess {number} parent
Id of the comment this comment is a reply to. `null` if this is a top-level-comment.
@apiSuccess {number=1,2,4} mode
The comments mode:
value | explanation
--- | ---
`1` | accepted: The comment was accepted by the server and is published.
`2` | in moderation queue: The comment was accepted by the server but awaits moderation.
`4` | deleted, but referenced: The comment was deleted on the server but is still referenced by replies.
@apiSuccess {string} author
The commentss authors name or `null`.
@apiSuccess {string} website
The comments authors website or `null`.
@apiSuccess {string} hash
A hash uniquely identifying the comments author.
@apiSuccess {number} created
UNIX timestamp of the time the comment was created (on the server).
@apiSuccess {number} modified
UNIX timestamp of the time the comment was last modified (on the server). `null` if the comment was not yet modified.
"""
"""
@api {post} /new create new
@apiGroup Comment
@apiDescription
Creates a new comment. The response will set a cookie on the requestor to enable them to later edit the comment.
@apiUse csrf
@apiParam {string} uri
The uri of the thread to create the comment on.
@apiParam {string} text
The comments raw text.
@apiParam {string} [author]
The comments authors name.
@apiParam {string} [email]
The comments authors email address.
@apiParam {string} [website]
The comments authors websites url.
@apiParam {number} [parent]
The parent comments id iff the new comment is a response to an existing comment.
@apiExample {curl} Create a reply to comment with id 15:
curl 'https://comments.example.com/new?uri=/thread/' -d '{"text": "Stop saying that! *isso*!", "author": "Max Rant", "email": "rant@example.com", "parent": 15}' -H 'Content-Type: application/json' -c cookie.txt
@apiUse commentResponse
@apiSuccessExample Success after the above request:
{
"website": null,
"author": "Max Rant",
"parent": 15,
"created": 1464940838.254393,
"text": "&lt;p&gt;Stop saying that! &lt;em&gt;isso&lt;/em&gt;!&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"hash": "e644f6ee43c0",
"id": 23,
"likes": 0
}
"""
@xhr
@requires(str, 'uri')
def new(self, environ, request, uri):
data = request.get_json()
for field in set(data.keys()) - API.ACCEPT:
data.pop(field)
for key in ("author", "email", "website", "parent"):
data.setdefault(key, None)
valid, reason = API.verify(data)
if not valid:
return BadRequest(reason)
for field in ("author", "email", "website"):
if data.get(field) is not None:
data[field] = escape(data[field], quote=False)
if data.get("website"):
data["website"] = normalize(data["website"])
data['mode'] = 2 if self.moderated else 1
data['remote_addr'] = self._remote_addr(request)
with self.isso.lock:
if uri not in self.threads:
if 'title' not in data:
with http.curl('GET', local("origin"), uri) as resp:
if resp and resp.status == 200:
uri, title = parse.thread(resp.read(), id=uri)
else:
return NotFound('URI does not exist %s')
else:
title = data['title']
thread = self.threads.new(uri, title)
self.signal("comments.new:new-thread", thread)
else:
thread = self.threads[uri]
# notify extensions that the new comment is about to save
self.signal("comments.new:before-save", thread, data)
valid, reason = self.guard.validate(uri, data)
if not valid:
self.signal("comments.new:guard", reason)
raise Forbidden(reason)
with self.isso.lock:
# if email-based auto-moderation enabled, check for previously approved author
# right before approval.
if self.approve_if_email_previously_approved and self.comments.is_previously_approved_author(data['email']):
data['mode'] = 1
rv = self.comments.add(uri, data)
# notify extension, that the new comment has been successfully saved
self.signal("comments.new:after-save", thread, rv)
cookie = functools.partial(dump_cookie,
value=self.isso.sign(
[rv["id"], sha1(rv["text"])]),
max_age=self.conf.getint('max-age'))
rv["text"] = self.isso.render(rv["text"])
rv["hash"] = self.hash(rv['email'] or rv['remote_addr'])
self.cache.set(
'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
rv = self._add_gravatar_image(rv)
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
# success!
self.signal("comments.new:finish", thread, rv)
resp = JSON(rv, 202 if rv["mode"] == 2 else 201)
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
def _remote_addr(self, request):
"""Return the anonymized IP address of the requester.
Takes into consideration a potential X-Forwarded-For HTTP header
if a necessary server.trusted-proxies configuration entry is set.
Recipe source: https://stackoverflow.com/a/22936947/636849
"""
remote_addr = request.remote_addr
if self.trusted_proxies:
route = request.access_route + [remote_addr]
remote_addr = next((addr for addr in reversed(route)
if addr not in self.trusted_proxies), remote_addr)
return utils.anonymize(str(remote_addr))
"""
@api {get} /id/:id view
@apiGroup Comment
@apiParam {number} id
The id of the comment to view.
@apiUse plainParam
@apiExample {curl} View the comment with id 4:
curl 'https://comments.example.com/id/4'
@apiUse commentResponse
@apiSuccessExample Example result:
{
"website": null,
"author": null,
"parent": null,
"created": 1464914341.312426,
"text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"id": 4,
"likes": 1
}
"""
def view(self, environ, request, id):
rv = self.comments.get(id)
if rv is None:
raise NotFound
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
if request.args.get('plain', '0') == '0':
rv['text'] = self.isso.render(rv['text'])
return JSON(rv, 200)
"""
@api {put} /id/:id edit
@apiGroup Comment
@apiDescription
Edit an existing comment. Editing a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details. Editing a comment will set a new edit cookie in the response.
@apiUse csrf
@apiParam {number} id
The id of the comment to edit.
@apiParam {string} text
A new (raw) text for the comment.
@apiParam {string} [author]
The modified comments authors name.
@apiParam {string} [webiste]
The modified comments authors website.
@apiExample {curl} Edit comment with id 23:
curl -X PUT 'https://comments.example.com/id/23' -d {"text": "I see your point. However, I still disagree.", "website": "maxrant.important.com"} -H 'Content-Type: application/json' -b cookie.txt
@apiUse commentResponse
@apiSuccessExample Example response:
{
"website": "maxrant.important.com",
"author": "Max Rant",
"parent": 15,
"created": 1464940838.254393,
"text": "&lt;p&gt;I see your point. However, I still disagree.&lt;/p&gt;",
"dislikes": 0,
"modified": 1464943439.073961,
"mode": 1,
"id": 23,
"likes": 0
}
"""
@xhr
def edit(self, environ, request, id):
try:
rv = self.isso.unsign(request.cookies.get(str(id), ''))
except (SignatureExpired, BadSignature):
raise Forbidden
if rv[0] != id:
raise Forbidden
# verify checksum, mallory might skip cookie deletion when he deletes a comment
if rv[1] != sha1(self.comments.get(id)["text"]):
raise Forbidden
data = request.get_json()
if "text" not in data or data["text"] is None or len(data["text"]) < 3:
raise BadRequest("no text given")
for key in set(data.keys()) - set(["text", "author", "website"]):
data.pop(key)
data['modified'] = time.time()
with self.isso.lock:
rv = self.comments.update(id, data)
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
self.signal("comments.edit", rv)
cookie = functools.partial(dump_cookie,
value=self.isso.sign(
[rv["id"], sha1(rv["text"])]),
max_age=self.conf.getint('max-age'))
rv["text"] = self.isso.render(rv["text"])
resp = JSON(rv, 200)
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
"""
@api {delete} '/id/:id' delete
@apiGroup Comment
@apiDescription
Delte an existing comment. Deleting a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details.
@apiParam {number} id
Id of the comment to delete.
@apiExample {curl} Delete comment with id 14:
curl -X DELETE 'https://comments.example.com/id/14' -b cookie.txt
@apiSuccessExample Successful deletion returns null:
null
"""
@xhr
def delete(self, environ, request, id, key=None):
try:
rv = self.isso.unsign(request.cookies.get(str(id), ""))
except (SignatureExpired, BadSignature):
raise Forbidden
else:
if rv[0] != id:
raise Forbidden
# verify checksum, mallory might skip cookie deletion when he deletes a comment
if rv[1] != sha1(self.comments.get(id)["text"]):
raise Forbidden
item = self.comments.get(id)
if item is None:
raise NotFound
self.cache.delete(
'hash', (item['email'] or item['remote_addr']).encode('utf-8'))
with self.isso.lock:
rv = self.comments.delete(id)
if rv:
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
self.signal("comments.delete", id)
resp = JSON(rv, 200)
cookie = functools.partial(dump_cookie, expires=0, max_age=0)
resp.headers.add("Set-Cookie", cookie(str(id)))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
"""
@api {get} /id/:id/:email/key unsubscribe
@apiGroup Comment
@apiDescription
Opt out from getting any further email notifications about replies to a particular comment. In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.
@apiParam {number} id
The id of the comment to unsubscribe from replies to.
@apiParam {string} email
The email address of the subscriber.
@apiParam {string} key
The key to authenticate the subscriber.
@apiExample {curl} Unsubscribe Alice from replies to comment with id 13:
curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos'
@apiSuccessExample {html} Using GET:
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;script&gt;
if (confirm('Delete: Are you sure?')) {
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
}
&lt;/script&gt;
@apiSuccessExample Using POST:
Yo
"""
def unsubscribe(self, environ, request, id, email, key):
email = unquote(email)
try:
rv = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
if rv[0] != 'unsubscribe' or rv[1] != email:
raise Forbidden
item = self.comments.get(id)
if item is None:
raise NotFound
with self.isso.lock:
self.comments.unsubscribe(email, id)
modal = (
"<!DOCTYPE html>"
"<html>"
"<head>"
" <title>Successfully unsubscribed</title>"
"</head>"
"<body>"
" <p>You have been unsubscribed from replies in the given conversation.</p>"
"</body>"
"</html>")
return Response(modal, 200, content_type="text/html")
"""
@api {post} /id/:id/:action/key moderate
@apiGroup Comment
@apiDescription
Publish or delete a comment that is in the moderation queue (mode `2`). In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.
This endpoint can also be used with a `GET` request. In that case, a html page is returned that asks the user whether they are sure to perform the selected action. If they select “yes”, the query is repeated using `POST`.
@apiParam {number} id
The id of the comment to moderate.
@apiParam {string=activate,delete} action
`activate` to publish the comment (change its mode to `1`).
`delete` to delete the comment
@apiParam {string} key
The moderation key to authenticate the moderation.
@apiExample {curl} delete comment with id 13:
curl -X POST 'https://comments.example.com/id/13/delete/MTM.CjL6Fg.REIdVXa-whJS_x8ojQL4RrXnuF4'
@apiSuccessExample {html} Using GET:
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;script&gt;
if (confirm('Delete: Are you sure?')) {
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
xhr.onload = function() {
window.location.href = "https://example.com/example-thread/#isso-13";
};
}
&lt;/script&gt;
@apiSuccessExample Using POST:
Yo
"""
def moderate(self, environ, request, id, action, key):
try:
id = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
item = self.comments.get(id)
thread = self.threads.get(item['tid'])
link = local("origin") + thread["uri"] + "#isso-%i" % item["id"]
if item is None:
raise NotFound
if request.method == "GET":
modal = (
"<!DOCTYPE html>"
"<html>"
"<head>"
"<script>"
" if (confirm('%s: Are you sure?')) {"
" xhr = new XMLHttpRequest;"
" xhr.open('POST', window.location.href);"
" xhr.send(null);"
" xhr.onload = function() {"
" window.location.href = %s;"
" };"
" }"
"</script>" % (action.capitalize(), json.dumps(link)))
return Response(modal, 200, content_type="text/html")
if action == "activate":
if item['mode'] == 1:
return Response("Already activated", 200)
with self.isso.lock:
self.comments.activate(id)
self.signal("comments.activate", thread, item)
return Response("Yo", 200)
elif action == "edit":
data = request.get_json()
with self.isso.lock:
rv = self.comments.update(id, data)
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
self.signal("comments.edit", rv)
return JSON(rv, 200)
else:
with self.isso.lock:
self.comments.delete(id)
self.cache.delete(
'hash', (item['email'] or item['remote_addr']).encode('utf-8'))
self.signal("comments.delete", id)
return Response("Yo", 200)
"""
@api {get} / get comments
@apiGroup Thread
@apiDescription Queries the comments of a thread.
@apiParam {string} uri
The URI of thread to get the comments from.
@apiParam {number} [parent]
Return only comments that are children of the comment with the provided ID.
@apiUse plainParam
@apiParam {number} [limit]
The maximum number of returned top-level comments. Omit for unlimited results.
@apiParam {number} [nested_limit]
The maximum number of returned nested comments per commint. Omit for unlimited results.
@apiParam {number} [after]
Includes only comments were added after the provided UNIX timestamp.
@apiSuccess {number} total_replies
The number of replies if the `limit` parameter was not set. If `after` is set to `X`, this is the number of comments that were created after `X`. So setting `after` may change this value!
@apiSuccess {Object[]} replies
The list of comments. Each comment also has the `total_replies`, `replies`, `id` and `hidden_replies` properties to represent nested comments.
@apiSuccess {number} id
Id of the comment `replies` is the list of replies of. `null` for the list of toplevel comments.
@apiSuccess {number} hidden_replies
The number of comments that were ommited from the results because of the `limit` request parameter. Usually, this will be `total_replies` - `limit`.
@apiExample {curl} Get 2 comments with 5 responses:
curl 'https://comments.example.com/?uri=/thread/&limit=2&nested_limit=5'
@apiSuccessExample Example reponse:
{
"total_replies": 14,
"replies": [
{
"website": null,
"author": null,
"parent": null,
"created": 1464818460.732863,
"text": "&lt;p&gt;Hello, World!&lt;/p&gt;",
"total_replies": 1,
"hidden_replies": 0,
"dislikes": 2,
"modified": null,
"mode": 1,
"replies": [
{
"website": null,
"author": null,
"parent": 1,
"created": 1464818460.769638,
"text": "&lt;p&gt;Hi, now some Markdown: &lt;em&gt;Italic&lt;/em&gt;, &lt;strong&gt;bold&lt;/strong&gt;, &lt;code&gt;monospace&lt;/code&gt;.&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"hash": "2af4e1a6c96a",
"id": 2,
"likes": 2
}
],
"hash": "1cb6cc0309a2",
"id": 1,
"likes": 2
},
{
"website": null,
"author": null,
"parent": null,
"created": 1464818460.80574,
"text": "&lt;p&gt;Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium at commodi cum deserunt dolore, error fugiat harum incidunt, ipsa ipsum mollitia nam provident rerum sapiente suscipit tempora vitae? Est, qui?&lt;/p&gt;",
"total_replies": 0,
"hidden_replies": 0,
"dislikes": 0,
"modified": null,
"mode": 1,
"replies": [],
"hash": "1cb6cc0309a2",
"id": 3,
"likes": 0
},
"id": null,
"hidden_replies": 12
}
"""
@requires(str, 'uri')
def fetch(self, environ, request, uri):
args = {
'uri': uri,
'after': request.args.get('after', 0)
}
try:
args['limit'] = int(request.args.get('limit'))
except TypeError:
args['limit'] = None
except ValueError:
return BadRequest("limit should be integer")
if request.args.get('parent') is not None:
try:
args['parent'] = int(request.args.get('parent'))
root_id = args['parent']
except ValueError:
return BadRequest("parent should be integer")
else:
args['parent'] = None
root_id = None
plain = request.args.get('plain', '0') == '0'
reply_counts = self.comments.reply_count(uri, after=args['after'])
if args['limit'] == 0:
root_list = []
else:
root_list = list(self.comments.fetch(**args))
if root_id not in reply_counts:
reply_counts[root_id] = 0
try:
nested_limit = int(request.args.get('nested_limit'))
except TypeError:
nested_limit = None
except ValueError:
return BadRequest("nested_limit should be integer")
rv = {
'id': root_id,
'total_replies': reply_counts[root_id],
'hidden_replies': reply_counts[root_id] - len(root_list),
'replies': self._process_fetched_list(root_list, plain)
}
# We are only checking for one level deep comments
if root_id is None:
for comment in rv['replies']:
if comment['id'] in reply_counts:
comment['total_replies'] = reply_counts[comment['id']]
if nested_limit is not None:
if nested_limit > 0:
args['parent'] = comment['id']
args['limit'] = nested_limit
replies = list(self.comments.fetch(**args))
else:
replies = []
else:
args['parent'] = comment['id']
replies = list(self.comments.fetch(**args))
else:
comment['total_replies'] = 0
replies = []
comment['hidden_replies'] = comment['total_replies'] - \
len(replies)
comment['replies'] = self._process_fetched_list(replies, plain)
return JSON(rv, 200)
def _add_gravatar_image(self, item):
if not self.conf.getboolean('gravatar'):
return item
email = item['email'] or item['author'] or ''
email_md5_hash = md5(email)
gravatar_url = self.conf.get('gravatar-url')
item['gravatar_image'] = gravatar_url.format(email_md5_hash)
return item
def _process_fetched_list(self, fetched_list, plain=False):
for item in fetched_list:
key = item['email'] or item['remote_addr']
val = self.cache.get('hash', key.encode('utf-8'))
if val is None:
val = self.hash(key)
self.cache.set('hash', key.encode('utf-8'), val)
item['hash'] = val
item = self._add_gravatar_image(item)
for key in set(item.keys()) - API.FIELDS:
item.pop(key)
if plain:
for item in fetched_list:
item['text'] = self.isso.render(item['text'])
return fetched_list
"""
@apiDefine likeResponse
@apiSuccess {number} likes
The (new) number of likes on the comment.
@apiSuccess {number} dislikes
The (new) number of dislikes on the comment.
"""
"""
@api {post} /id/:id/like like
@apiGroup Comment
@apiDescription
Puts a “like” on a comment. The author of a comment cannot like its own comment.
@apiParam {number} id
The id of the comment to like.
@apiExample {curl} Like comment with id 23:
curl -X POST 'https://comments.example.com/id/23/like'
@apiUse likeResponse
@apiSuccessExample Example response
{
"likes": 5,
"dislikes": 2
}
"""
@xhr
def like(self, environ, request, id):
nv = self.comments.vote(True, id, self._remote_addr(request))
return JSON(nv, 200)
"""
@api {post} /id/:id/dislike dislike
@apiGroup Comment
@apiDescription
Puts a “dislike” on a comment. The author of a comment cannot dislike its own comment.
@apiParam {number} id
The id of the comment to dislike.
@apiExample {curl} Dislike comment with id 23:
curl -X POST 'https://comments.example.com/id/23/dislike'
@apiUse likeResponse
@apiSuccessExample Example response
{
"likes": 4,
"dislikes": 3
}
"""
@xhr
def dislike(self, environ, request, id):
nv = self.comments.vote(False, id, self._remote_addr(request))
return JSON(nv, 200)
# TODO: remove someday (replaced by :func:`counts`)
@requires(str, 'uri')
def count(self, environ, request, uri):
rv = self.comments.count(uri)[0]
if rv == 0:
raise NotFound
return JSON(rv, 200)
"""
@api {post} /count count comments
@apiGroup Thread
@apiDescription
Counts the number of comments on multiple threads. The requestor provides a list of thread uris. The number of comments on each thread is returned as a list, in the same order as the threads were requested. The counts include comments that are reponses to comments.
@apiExample {curl} get the count of 5 threads:
curl 'https://comments.example.com/count' -d '["/blog/firstPost.html", "/blog/controversalPost.html", "/blog/howToCode.html", "/blog/boringPost.html", "/blog/isso.html"]
@apiSuccessExample Counts of 5 threads:
[2, 18, 4, 0, 3]
"""
def counts(self, environ, request):
data = request.get_json()
if not isinstance(data, list) and not all(isinstance(x, str) for x in data):
raise BadRequest("JSON must be a list of URLs")
return JSON(self.comments.count(*data), 200)
"""
@api {get} /feed Atom feed for comments
@apiGroup Thread
@apiDescription
Provide an Atom feed for the given thread.
"""
@requires(str, 'uri')
def feed(self, environ, request, uri):
conf = self.isso.conf.section("rss")
if not conf.get('base'):
raise NotFound
args = {
'uri': uri,
'order_by': 'id',
'asc': 0,
'limit': conf.getint('limit')
}
try:
args['limit'] = max(int(request.args.get('limit')), args['limit'])
except TypeError:
pass
except ValueError:
return BadRequest("limit should be integer")
comments = self.comments.fetch(**args)
base = conf.get('base').rstrip('/')
hostname = urlparse(base).netloc
# Let's build an Atom feed.
# RFC 4287: https://tools.ietf.org/html/rfc4287
# RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions)
# For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
feed = ET.Element('feed', {
'xmlns': 'http://www.w3.org/2005/Atom',
'xmlns:thr': 'http://purl.org/syndication/thread/1.0'
})
# For feed ID, we would use thread ID, but we may not have
# one. Therefore, we use the URI. We don't have a year
# either...
id = ET.SubElement(feed, 'id')
id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format(
hostname=hostname, uri=uri)
# For title, we don't have much either. Be pretty generic.
title = ET.SubElement(feed, 'title')
title.text = 'Comments for {hostname}{uri}'.format(
hostname=hostname, uri=uri)
comment0 = None
for comment in comments:
if comment0 is None:
comment0 = comment
entry = ET.SubElement(feed, 'entry')
# We don't use a real date in ID either to help with
# threading.
id = ET.SubElement(entry, 'id')
id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['id'])
title = ET.SubElement(entry, 'title')
title.text = 'Comment #{}'.format(comment['id'])
updated = ET.SubElement(entry, 'updated')
updated.text = '{}Z'.format(datetime.fromtimestamp(
comment['modified'] or comment['created']).isoformat())
author = ET.SubElement(entry, 'author')
name = ET.SubElement(author, 'name')
name.text = comment['author']
ET.SubElement(entry, 'link', {
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['id'])
})
content = ET.SubElement(entry, 'content', {
'type': 'html',
})
content.text = self.isso.render(comment['text'])
if comment['parent']:
ET.SubElement(entry, 'thr:in-reply-to', {
'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['parent']),
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['parent'])
})
# Updated is mandatory. If we have comments, we use the date
# of last modification of the first one (which is the last
# one). Otherwise, we use a fixed date.
updated = ET.Element('updated')
if comment0 is None:
updated.text = '1970-01-01T01:00:00Z'
else:
updated.text = datetime.fromtimestamp(
comment0['modified'] or comment0['created']).isoformat()
updated.text += 'Z'
feed.insert(0, updated)
output = StringIO()
ET.ElementTree(feed).write(output,
encoding='utf-8',
xml_declaration=True)
response = XML(output.getvalue(), 200)
# Add an etag/last-modified value for caching purpose
if comment0 is None:
response.set_etag('empty')
response.last_modified = 0
else:
response.set_etag('{tid}-{id}'.format(**comment0))
response.last_modified = comment0['modified'] or comment0['created']
return response.make_conditional(request)
def preview(self, environment, request):
data = request.get_json()
if "text" not in data or data["text"] is None:
raise BadRequest("no text given")
return JSON({'text': self.isso.render(data["text"])}, 200)
def demo(self, env, req):
return redirect(
get_current_url(env, strip_querystring=True) + '/index.html'
)
def login(self, env, req):
if not self.isso.conf.getboolean("admin", "enabled"):
isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
return render_template('disabled.html', isso_host_script=isso_host_script)
data = req.form
password = self.isso.conf.get("admin", "password")
if data['password'] and data['password'] == password:
response = redirect(re.sub(
r'/login$',
'/admin',
get_current_url(env, strip_querystring=True)
))
cookie = functools.partial(dump_cookie,
value=self.isso.sign({"logged": True}),
expires=datetime.now() + timedelta(1))
response.headers.add("Set-Cookie", cookie("admin-session"))
response.headers.add("X-Set-Cookie", cookie("isso-admin-session"))
return response
else:
isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
return render_template('login.html', isso_host_script=isso_host_script)
def admin(self, env, req):
isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
if not self.isso.conf.getboolean("admin", "enabled"):
return render_template('disabled.html', isso_host_script=isso_host_script)
try:
data = self.isso.unsign(req.cookies.get('admin-session', ''),
max_age=60 * 60 * 24)
except BadSignature:
return render_template('login.html', isso_host_script=isso_host_script)
if not data or not data['logged']:
return render_template('login.html', isso_host_script=isso_host_script)
page_size = 100
page = int(req.args.get('page', 0))
order_by = req.args.get('order_by', None)
asc = int(req.args.get('asc', 1))
mode = int(req.args.get('mode', 2))
comments = self.comments.fetchall(mode=mode, page=page,
limit=page_size,
order_by=order_by,
asc=asc)
comments_enriched = []
for comment in list(comments):
comment['hash'] = self.isso.sign(comment['id'])
comments_enriched.append(comment)
comment_mode_count = self.comments.count_modes()
max_page = int(sum(comment_mode_count.values()) / 100)
return render_template('admin.html', comments=comments_enriched,
page=int(page), mode=int(mode),
conf=self.conf, max_page=max_page,
counts=comment_mode_count,
order_by=order_by, asc=asc,
isso_host_script=isso_host_script)
"""
@api {get} /latest latest
@apiGroup Comment
@apiDescription
Get the latest comments from the system, no matter which thread
@apiParam {number} limit
The quantity of last comments to retrieve
@apiExample {curl} Get the latest 5 comments
curl 'https://comments.example.com/latest?limit=5'
@apiUse commentResponse
@apiSuccessExample Example result:
[
{
"website": null,
"uri": "/some",
"author": null,
"parent": null,
"created": 1464912312.123416,
"text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"id": 3,
"likes": 1
},
{
"website": null,
"uri": "/other",
"author": null,
"parent": null,
"created": 1464914341.312426,
"text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"id": 4,
"likes": 0
}
]
"""
def latest(self, environ, request):
# if the feature is not allowed, don't present the endpoint
if not self.conf.getboolean("latest-enabled"):
return NotFound()
# get and check the limit
bad_limit_msg = "Query parameter 'limit' is mandatory (integer, >0)"
try:
limit = int(request.args['limit'])
except (KeyError, ValueError):
return BadRequest(bad_limit_msg)
if limit <= 0:
return BadRequest(bad_limit_msg)
# retrieve the latest N comments from the DB
all_comments_gen = self.comments.fetchall(limit=None, order_by='created', mode='1')
comments = collections.deque(all_comments_gen, maxlen=limit)
# prepare a special set of fields (except text which is rendered specifically)
fields = {
'author',
'created',
'dislikes',
'id',
'likes',
'mode',
'modified',
'parent',
'text',
'uri',
'website',
}
# process the retrieved comments and build results
result = []
for comment in comments:
processed = {key: comment[key] for key in fields}
processed['text'] = self.isso.render(comment['text'])
result.append(processed)
return JSON(result, 200)