# -*- encoding: utf-8 -*- from __future__ import unicode_literals import collections import re import time import functools import json # json.dumps to put URL in " % (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": "<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 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": " <p>I want to use MySQL</p>", "dislikes": 0, "modified": null, "mode": 1, "id": 3, "likes": 1 }, { "website": null, "uri": "/other", "author": null, "parent": null, "created": 1464914341.312426, "text": " <p>I want to use MySQL</p>", "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)