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

# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import collections
10 years ago
import re
import time
import functools
import json # json.dumps to put URL in <script>
from datetime import datetime, timedelta
12 years ago
from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET
12 years ago
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
10 years ago
# 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,}\.?)|'
10 years ago
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"
10 years ago
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)
10 years ago
for field in ("author", "email", "website"):
if data.get(field) is not None:
data[field] = escape(data[field], quote=False)
10 years ago
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
12 years ago
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
12 years ago
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
12 years ago
"""
@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)
8 years ago
"""
@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
8 years ago
@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!
8 years ago
@apiSuccess {Object[]} replies
The list of comments. Each comment also has the `total_replies`, `replies`, `id` and `hidden_replies` properties to represent nested comments.
8 years ago
@apiSuccess {number} id
Id of the comment `replies` is the list of replies of. `null` for the list of toplevel comments.
8 years ago
@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`.
8 years ago
@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):
12 years ago
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
12 years ago
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.
8 years ago
@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"]
8 years ago
@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)
8 years ago
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)