|
|
|
@ -9,6 +9,7 @@ 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
|
|
|
|
@ -20,10 +21,21 @@ 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,
|
|
|
|
|
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 StringIO import StringIO
|
|
|
|
|
except ImportError:
|
|
|
|
|
from io import BytesIO as StringIO
|
|
|
|
|
|
|
|
|
|
# from Django appearently, looks good to me *duck*
|
|
|
|
|
__url_re = re.compile(
|
|
|
|
@ -81,7 +93,7 @@ def xhr(func):
|
|
|
|
|
class API(object):
|
|
|
|
|
|
|
|
|
|
FIELDS = set(['id', 'parent', 'text', 'author', 'website',
|
|
|
|
|
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash'])
|
|
|
|
|
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image'])
|
|
|
|
|
|
|
|
|
|
# comment fields, that can be submitted
|
|
|
|
|
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title'])
|
|
|
|
@ -91,6 +103,7 @@ class API(object):
|
|
|
|
|
('new', ('POST', '/new')),
|
|
|
|
|
('count', ('GET', '/count')),
|
|
|
|
|
('counts', ('POST', '/count')),
|
|
|
|
|
('feed', ('GET', '/feed')),
|
|
|
|
|
('view', ('GET', '/id/<int:id>')),
|
|
|
|
|
('edit', ('PUT', '/id/<int:id>')),
|
|
|
|
|
('delete', ('DELETE', '/id/<int:id>')),
|
|
|
|
@ -291,6 +304,8 @@ class API(object):
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
@ -718,6 +733,18 @@ class API(object):
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
@ -730,6 +757,8 @@ class API(object):
|
|
|
|
|
|
|
|
|
|
item['hash'] = val
|
|
|
|
|
|
|
|
|
|
item = self._add_gravatar_image(item)
|
|
|
|
|
|
|
|
|
|
for key in set(item.keys()) - API.FIELDS:
|
|
|
|
|
item.pop(key)
|
|
|
|
|
|
|
|
|
@ -824,7 +853,6 @@ class API(object):
|
|
|
|
|
@apiSuccessExample Counts of 5 threads:
|
|
|
|
|
[2, 18, 4, 0, 3]
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def counts(self, environ, request):
|
|
|
|
|
|
|
|
|
|
data = request.get_json()
|
|
|
|
@ -834,6 +862,125 @@ class API(object):
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
@ -843,14 +990,19 @@ class API(object):
|
|
|
|
|
return JSON({'text': self.isso.render(data["text"])}, 200)
|
|
|
|
|
|
|
|
|
|
def demo(self, env, req):
|
|
|
|
|
return redirect(get_current_url(env) + '/index.html')
|
|
|
|
|
return redirect(
|
|
|
|
|
get_current_url(env, strip_querystring=True) + '/index.html'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def login(self, env, req):
|
|
|
|
|
data = req.form
|
|
|
|
|
password = self.isso.conf.get("general", "admin_password")
|
|
|
|
|
if data['password'] and data['password'] == password:
|
|
|
|
|
response = redirect(get_current_url(
|
|
|
|
|
env, host_only=True) + '/admin')
|
|
|
|
|
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))
|
|
|
|
|