# -*- encoding: utf-8 -*- from __future__ import unicode_literals import re import cgi import time import functools 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 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
element and JS to execute automatically (see #40 for a proof-of-concept). When an attacker uses a 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')), ('view', ('GET', '/id/')), ('edit', ('PUT', '/id/')), ('delete', ('DELETE', '/id/')), ('unsubscribe', ('GET', '/id//unsubscribe//')), ('moderate', ('GET', '/id///')), ('moderate', ('POST', '/id///')), ('like', ('POST', '/id//like')), ('dislike', ('POST', '/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") 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 comment’s 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 comment’s 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 comments’s author’s name or `null`. @apiSuccess {string} website The comment’s author’s website or `null`. @apiSuccess {string} hash A hash uniquely identifying the comment’s 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 comment’s raw text. @apiParam {string} [author] The comment’s author’s name. @apiParam {string} [email] The comment’s author’s email address. @apiParam {string} [website] The comment’s author’s website’s url. @apiParam {number} [parent] The parent comment’s 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": "<p>Stop saying that! <em>isso</em>!</p>", "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] = cgi.escape(data[field]) if data.get("website"): data["website"] = normalize(data["website"]) data['mode'] = 2 if self.moderated else 1 data['remote_addr'] = utils.anonymize(str(request.remote_addr)) 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: 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 """ @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": " <p>I want to use MySQL</p>", "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 comment’s author’s name. @apiParam {string} [webiste] The modified comment’s author’s 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": "<p>I see your point. However, I still disagree.</p>", "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: <!DOCTYPE html> <html> <head> <script> if (confirm('Delete: Are you sure?')) { xhr = new XMLHttpRequest; xhr.open('POST', window.location.href); xhr.send(null); } </script> @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 = ( "" "" "" " Successfully unsubscribed" "" "" "

You have been unsubscribed from replies in the given conversation.

" "" "") 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: <!DOCTYPE html> <html> <head> <script> if (confirm('Delete: Are you sure?')) { xhr = new XMLHttpRequest; xhr.open('POST', window.location.href); xhr.send(null); } </script> @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) if item is None: raise NotFound if request.method == "GET": modal = ( "" "" "" "" % action.capitalize()) 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) thread = self.threads.get(item['tid']) 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": "<p>Hello, World!</p>", "total_replies": 1, "hidden_replies": 0, "dislikes": 2, "modified": null, "mode": 1, "replies": [ { "website": null, "author": null, "parent": 1, "created": 1464818460.769638, "text": "<p>Hi, now some Markdown: <em>Italic</em>, <strong>bold</strong>, <code>monospace</code>.</p>", "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": "<p>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?</p>", "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 not root_list: raise NotFound 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 "" 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, utils.anonymize(str(request.remote_addr))) 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, utils.anonymize(str(request.remote_addr))) 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"): return render_template('disabled.html') 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') 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)