diff --git a/MANIFEST.in b/MANIFEST.in
index c4edbe1..4c63d8e 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -9,3 +9,9 @@ include isso/js/count.min.js
include isso/js/count.dev.js
include isso/defaults.ini
+
+include isso/templates/admin.html
+include isso/templates/login.html
+include isso/css/admin.css
+include isso/css/isso.css
+include isso/img/isso.svg
diff --git a/isso/__init__.py b/isso/__init__.py
index 6d42c53..035cafd 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..c440528
--- /dev/null
+++ b/isso/css/admin.css
@@ -0,0 +1,134 @@
+* {
+ 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%;
+}
+.isso-comment-footer a {
+ cursor: pointer;
+}
+.thread-title {
+ margin-left: 3em;
+}
+.group {
+ float: left;
+ margin-left: 2em;
+}
+.editable {
+ border: 1px solid #aaa;
+ border-radius: 5px;
+ margin: 10px;
+ padding: 5px;
+}
+.hidden {
+ display: none;
+}
diff --git a/isso/db/comments.py b/isso/db/comments.py
index 496a4e5..a66c248 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,64 @@ 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, asc=1):
+ """
+ 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', 'tid']:
+ sql.append('ORDER BY ')
+ sql.append("comments.created")
+ if not asc:
+ sql.append(' DESC')
+ else:
+ sql.append('ORDER BY ')
+ sql.append('comments.' + order_by)
+ if not asc:
+ sql.append(' DESC')
+ sql.append(", comments.created")
+
+ 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..484a949
--- /dev/null
+++ b/isso/templates/admin.html
@@ -0,0 +1,253 @@
+
+
+ Isso admin
+
+
+
+
+
+
+
+
+
+
+
+ Group by thread:
+
+
+
+
+
+
+ {% set thread_id = "no_id" %}
+ {% for comment in comments %}
+ {% if order_by == "tid" %}
+ {% if thread_id != comment.tid %}
+ {% set thread_id = comment.tid %}
+
+ {% endif %}
+ {% endif %}
+
+ {% 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 1fd7626..226cbcd 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
@@ -91,12 +94,14 @@ class API(object):
('view', ('GET', '/id/')),
('edit', ('PUT', '/id/')),
('delete', ('DELETE', '/id/')),
- ('moderate',('GET', '/id///')),
- ('moderate',('POST', '/id///')),
+ ('moderate',('GET', '/id///')),
+ ('moderate',('POST', '/id///')),
('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):
@@ -502,7 +507,6 @@ class API(object):
Yo
"""
def moderate(self, environ, request, id, action, key):
-
try:
id = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
@@ -532,13 +536,21 @@ class API(object):
with self.isso.lock:
self.comments.activate(id)
self.signal("comments.activate", id)
+ 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)
+ return Response("Yo", 200)
"""
@@ -822,3 +834,46 @@ 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 = 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)
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 9154cd5..883e2b1 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.
+ {% endif %} + {% if comment.author %} + {{comment.author}} + {% else %} + Anonymous + {% endif %} + {% if comment.email %} + ({{comment.email}} mailto) + {% else %} + + {% endif %} + {% if comment.website %} + ({{comment.website}} open) + {% else %} + + {% endif %} + • + + + {% if comment.mode == 1 %} + Valid + {% elif comment.mode == 2 %} + Pending + {% elif comment.mode == 4 %} + Staled + {% endif %} + +
+ {% endif %} +