diff --git a/isso/__init__.py b/isso/__init__.py index 7795c4f..f842882 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -186,6 +186,7 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False): wrapper.append(partial(SharedDataMiddleware, exports={ '/js': join(dirname(__file__), 'js/'), '/css': join(dirname(__file__), 'css/'), + '/img': join(dirname(__file__), 'img/'), '/demo': join(dirname(__file__), 'demo/') })) diff --git a/isso/css/admin.css b/isso/css/admin.css new file mode 100644 index 0000000..6f747b9 --- /dev/null +++ b/isso/css/admin.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} +h1, h2, h3, h4, h5, h6 { + font-style: normal; + font-weight: normal; +} +input { + text-align: center; +} +.header::before, .header::after { + content: " "; + display: table; +} +.header::after { + clear: both; +} +.header::before, .header::after { + content: " "; + display: table; +} +.header { + margin-left: auto; + margin-right: auto; + max-width: 68em; + padding-bottom: 1em; + padding-top: 1em; +} +.header header { + display: block; + float: left; + font-weight: normal; + margin-right: 16.0363%; + width: 41.9818%; +} +.header header .logo { + float: left; + max-height: 60px; + padding-right: 12px; +} +.header header h1 { + font-size: 1.55em; + margin-bottom: 0.3em; +} +.header header h2 { + font-size: 1.05em; +} +.header a, .header a:visited { + color: #4d4c4c; + text-decoration: none; +} +.outer { + background-color: #eeeeee; + box-shadow: 0 0 0.5em #c0c0c0 inset; +} +.outer .filters::before, .outer .filters::after { + content: " "; + display: table; +} +.outer .filters::after { + clear: both; +} +.outer .filters::before, .outer .filters::after { + content: " "; + display: table; +} +.outer .filters { + margin-left: auto; + margin-right: auto; + max-width: 68em; + padding: 1em; +} + +a { + text-decoration: none; + color: #4d4c4c; +} +.label { + background-color: #ddd; + border: 1px solid #ccc; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + cursor: pointer; + line-height: 1.4em; + outline: 0 none; + padding: calc(0.6em - 1px); +} +.active { + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6) inset; +} +.label-valid { + background-color: #cfc; + border-color: #cfc; +} +.label-pending { + background-color: #ffc; + border-color: #ffc; +} +.mode { + float: left; +} +.pagination { + float: right; +} +.note .label { + margin: 9px; + padding: 3px; +} +#login { + margin-top: 40px; + text-align: center; + width: 100%; +} diff --git a/isso/db/comments.py b/isso/db/comments.py index 496a4e5..69790ac 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -19,8 +19,11 @@ class Comments: The tuple (tid, id) is unique and thus primary key. """ - fields = ['tid', 'id', 'parent', 'created', 'modified', 'mode', 'remote_addr', - 'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters'] + fields = ['tid', 'id', 'parent', 'created', 'modified', + 'mode', # status of the comment 1 = valid, 2 = pending, + # 4 = soft-deleted (cannot hard delete because of replies) + 'remote_addr', 'text', 'author', 'email', 'website', + 'likes', 'dislikes', 'voters'] def __init__(self, db): @@ -97,6 +100,58 @@ class Comments: return None + def count_modes(self): + """ + Return comment mode counts for admin + """ + comment_count = self.db.execute( + 'SELECT mode, COUNT(comments.id) FROM comments ' + 'GROUP BY comments.mode').fetchall() + return dict(comment_count) + + def fetchall(self, mode=5, after=0, parent='any', order_by='id', + limit=100, page=0): + """ + Return comments for admin with :param:`mode`. + """ + fields_comments = ['tid', 'id', 'parent', 'created', 'modified', + 'mode', 'remote_addr', 'text', 'author', + 'email', 'website', 'likes', 'dislikes'] + fields_threads = ['uri', 'title'] + sql_comments_fields = ', '.join(['comments.' + f + for f in fields_comments]) + sql_threads_fields = ', '.join(['threads.' + f + for f in fields_threads]) + sql = ['SELECT ' + sql_comments_fields + ', ' + \ + sql_threads_fields + ' ' + 'FROM comments INNER JOIN threads ' + 'ON comments.tid=threads.id ' + 'WHERE comments.mode = ? '] + sql_args = [mode] + + if parent != 'any': + if parent is None: + sql.append('AND comments.parent IS NULL') + else: + sql.append('AND comments.parent=?') + sql_args.append(parent) + + # custom sanitization + if order_by not in ['id', 'created', 'modified', 'likes', 'dislikes']: + order_by = 'id' + sql.append('ORDER BY ') + sql.append('comments.' + order_by) + sql.append(' DESC') + + if limit: + sql.append('LIMIT ?,?') + sql_args.append(page * limit) + sql_args.append(limit) + + rv = self.db.execute(sql, sql_args).fetchall() + for item in rv: + yield dict(zip(fields_comments + fields_threads, item)) + def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None): """ Return comments for :param:`uri` with :param:`mode`. diff --git a/isso/img/isso.svg b/isso/img/isso.svg new file mode 100644 index 0000000..1aba2dd --- /dev/null +++ b/isso/img/isso.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/isso/templates/admin.html b/isso/templates/admin.html new file mode 100644 index 0000000..f3d9a53 --- /dev/null +++ b/isso/templates/admin.html @@ -0,0 +1,154 @@ + + + Isso admin + + + + + +
+
+
+ + +
+
+
+
+ + +
+
+
+ {% for comment in comments %} +
+ {% if conf.avatar %} +
+ svg(data-hash='#{{comment.hash}}') +
+ {% endif %} +
+
+ {% if comment.author %} + {{comment.author}} + {% else %} + Anonymous + {% endif %} + {% if comment.website %} + ({{comment.website}}) + {% endif %} + + + + {% if comment.mode == 1 %} + Valid + {% elif comment.mode == 2 %} + Pending + {% elif comment.mode == 4 %} + Staled + {% endif %} + +
+
+ {% if comment.mode == 4 %} + HIDDEN. Original text:
+ {% endif %} + {{comment.text}} +
+ +
+
+ {% endfor %} +
+
+ + diff --git a/isso/templates/login.html b/isso/templates/login.html new file mode 100644 index 0000000..a9aa888 --- /dev/null +++ b/isso/templates/login.html @@ -0,0 +1,30 @@ + + + Isso admin + + + + +
+
+
+ + +
+
+
+
+ Administration secured by password: +
+ +
+
+
+
+ + diff --git a/isso/utils/__init__.py b/isso/utils/__init__.py index a7cafd5..7536041 100644 --- a/isso/utils/__init__.py +++ b/isso/utils/__init__.py @@ -5,9 +5,12 @@ from __future__ import division, unicode_literals import pkg_resources werkzeug = pkg_resources.get_distribution("werkzeug") -import json import hashlib +import json +import os +from datetime import datetime +from jinja2 import Environment, FileSystemLoader from werkzeug.wrappers import Response from werkzeug.exceptions import BadRequest @@ -109,6 +112,19 @@ class JSONRequest(Request): raise BadRequest('Unable to read JSON request') +def render_template(template_name, **context): + template_path = os.path.join(os.path.dirname(__file__), + '..', 'templates') + jinja_env = Environment(loader=FileSystemLoader(template_path), + autoescape=True) + def datetimeformat(value): + return datetime.fromtimestamp(value).strftime('%H:%M / %d-%m-%Y') + + jinja_env.filters['datetimeformat'] = datetimeformat + t = jinja_env.get_template(template_name) + return Response(t.render(context), mimetype='text/html') + + class JSONResponse(Response): def __init__(self, obj, *args, **kwargs): diff --git a/isso/views/comments.py b/isso/views/comments.py index d328305..68c228f 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -7,6 +7,7 @@ import cgi import time import functools +from datetime import datetime, timedelta from itsdangerous import SignatureExpired, BadSignature from werkzeug.http import dump_cookie @@ -15,11 +16,13 @@ from werkzeug.utils import redirect from werkzeug.routing import Rule from werkzeug.wrappers import Response from werkzeug.exceptions import BadRequest, Forbidden, NotFound +from werkzeug.contrib.securecookie import SecureCookie 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, + render_template) from isso.views import requires from isso.utils.hash import sha1 @@ -90,7 +93,9 @@ class API(object): ('like', ('POST', '/id//like')), ('dislike', ('POST', '/id//dislike')), ('demo', ('GET', '/demo')), - ('preview', ('POST', '/preview')) + ('preview', ('POST', '/preview')), + ('login', ('POST', '/login')), + ('admin', ('GET', '/admin')) ] def __init__(self, isso, hasher): @@ -490,3 +495,41 @@ class API(object): def demo(self, env, req): return redirect(get_current_url(env) + '/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') + 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: + return render_template('login.html') + + def admin(self, env, req): + try: + data = self.isso.unsign(req.cookies.get('admin-session', ''), + max_age=60 * 60 * 24) + except BadSignature: + return render_template('login.html') + if not data or not data['logged']: + return render_template('login.html') + page_size = 100 + page = req.args.get('page', 0) + mode = req.args.get('mode', 2) + comments = self.comments.fetchall(mode=mode, page=page, + limit=page_size) + 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) diff --git a/setup.py b/setup.py index e54c110..b2c7482 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import sys from setuptools import setup, find_packages -requires = ['itsdangerous', 'misaka>=1.0,<2.0', 'html5lib==0.9999999'] +requires = ['itsdangerous', 'misaka>=1.0,<2.0', 'html5lib==0.9999999', 'Jinja2'] if (3, 0) <= sys.version_info < (3, 3): raise SystemExit("Python 3.0, 3.1 and 3.2 are not supported") diff --git a/share/isso-dev.conf b/share/isso-dev.conf index ce87ab3..970c1b0 100644 --- a/share/isso-dev.conf +++ b/share/isso-dev.conf @@ -10,6 +10,7 @@ host = http://isso-dev.local/ max-age = 15m notify = stdout log-file = /var/log/isso.log +admin_password = strong_default_password_for_isso_admin [moderation] enabled = false diff --git a/share/isso.conf b/share/isso.conf index 7c275ac..5dcf987 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -43,9 +43,12 @@ max-age = 15m # moderated) and deletion links. notify = stdout -# Log console messages to file instead of standard out. +# Log console messages to file instead of standard output. log-file = +# Admin access password +admin_password = please_choose_a_strong_password + [moderation] # enable comment moderation queue. This option only affects new comments.