Merge pull request #256 from blatinier/issue-10/admin-interface

Add a basic admin interface (Fix issue #10)
There are more to add in the interface but it's a good start.
This commit is contained in:
Benoît Latinier 2017-11-27 22:55:53 +01:00 committed by GitHub
commit d2b573a4d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 574 additions and 12 deletions

View File

@ -9,3 +9,9 @@ include isso/js/count.min.js
include isso/js/count.dev.js include isso/js/count.dev.js
include isso/defaults.ini 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

View File

@ -186,6 +186,7 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
wrapper.append(partial(SharedDataMiddleware, exports={ wrapper.append(partial(SharedDataMiddleware, exports={
'/js': join(dirname(__file__), 'js/'), '/js': join(dirname(__file__), 'js/'),
'/css': join(dirname(__file__), 'css/'), '/css': join(dirname(__file__), 'css/'),
'/img': join(dirname(__file__), 'img/'),
'/demo': join(dirname(__file__), 'demo/') '/demo': join(dirname(__file__), 'demo/')
})) }))

134
isso/css/admin.css Normal file
View File

@ -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;
}

View File

@ -19,8 +19,11 @@ class Comments:
The tuple (tid, id) is unique and thus primary key. The tuple (tid, id) is unique and thus primary key.
""" """
fields = ['tid', 'id', 'parent', 'created', 'modified', 'mode', 'remote_addr', fields = ['tid', 'id', 'parent', 'created', 'modified',
'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters'] '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): def __init__(self, db):
@ -97,6 +100,64 @@ class Comments:
return None 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): def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None):
""" """
Return comments for :param:`uri` with :param:`mode`. Return comments for :param:`uri` with :param:`mode`.

2
isso/img/isso.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

253
isso/templates/admin.html Normal file
View File

@ -0,0 +1,253 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="/css/isso.css" rel="stylesheet">
<link type="text/css" href="/css/admin.css" rel="stylesheet">
</head>
<body>
<script type="text/javascript">
function ajax(req) {
var r = new XMLHttpRequest();
r.open(req.method, req.url, true);
r.onreadystatechange = function () {
if (r.readyState != 4 || r.status != 200) {
if (req.failure) {
req.failure();
}
return;
}
req.success(r.responseText);
};
r.send(req.data);
}
function fade(element) {
var op = 1; // initial opacity
var timer = setInterval(function () {
if (op <= 0.1){
clearInterval(timer);
element.style.display = 'none';
}
element.style.opacity = op;
element.style.filter = 'alpha(opacity=' + op * 100 + ")";
op -= op * 0.1;
}, 10);
}
function moderate(com_id, hash, action) {
ajax({method: "POST",
url: "/id/" + com_id + "/" + action + "/" + hash,
success: function(){
fade(document.getElementById("isso-" + com_id));
}});
}
function edit(com_id, hash, author, email, website, comment) {
ajax({method: "POST",
url: "/id/" + com_id + "/edit/" + hash,
data: JSON.stringify({text: comment,
author: author,
email: email,
website: website}),
success: function(ret){
console.log("edit successed: ", ret);// TODO display some pretty stuff & update msg
},
error: function(ret){
console.log("Error: ", ret); // TODO flash msg/notif
}});
}
function validate_com(com_id, hash) {
moderate(com_id, hash, "activate");
}
function delete_com(com_id, hash) {
moderate(com_id, hash, "delete");
}
function unset_editable(elt_id) {
var elt = document.getElementById(elt_id);
if (elt) {
elt.contentEditable = false;
elt.classList.remove("editable");
}
}
function set_editable(elt_id) {
var elt = document.getElementById(elt_id);
if (elt) {
elt.contentEditable = true;
elt.classList.add("editable");
}
}
function start_edit(com_id) {
var editable_elements = ['isso-author-' + com_id,
'isso-email-' + com_id,
'isso-website-' + com_id,
'isso-text-' + com_id];
for (var idx=0; idx <= editable_elements.length; idx++) {
set_editable(editable_elements[idx]);
}
document.getElementById('edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('stop-edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('send-edit-btn-' + com_id).classList.toggle('hidden');
}
function stop_edit(com_id) {
var editable_elements = ['isso-author-' + com_id,
'isso-email-' + com_id,
'isso-website-' + com_id,
'isso-text-' + com_id];
for (var idx=0; idx <= editable_elements.length; idx++) {
unset_editable(editable_elements[idx]);
}
document.getElementById('edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('stop-edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('send-edit-btn-' + com_id).classList.toggle('hidden');
}
function send_edit(com_id, hash) {
var author = document.getElementById('isso-author-' + com_id).textContent;
var email = document.getElementById('isso-email-' + com_id).textContent;
var website = document.getElementById('isso-website-' + com_id).textContent;
var comment = document.getElementById('isso-text-' + com_id).textContent;
edit(com_id, hash, author, email, website, comment);
stop_edit(com_id);
}
</script>
<div class="wrapper">
<div class="header">
<header>
<img class="logo" src="/img/isso.svg" alt="Wynaut by @veekun"/>
<div class="title">
<a href="./">
<h1>Isso</h1>
<h2>Administration</h2>
</a>
</div>
</header>
</div>
<div class="outer">
<div class="filters">
<div class="mode">
<a href="?mode=1&page={{page}}&order_by={{order_by}}">
<span class="label label-valid {% if mode == 1 %}active{% endif %}">
Valid ({{counts.get(1, 0)}})
</span>
</a>
<a href="?mode=2&page={{page}}&order_by={{order_by}}">
<span class="label label-pending {% if mode == 2 %}active{% endif %}">
Pending ({{counts.get(2, 0)}})
</span>
</a>
<a href="?mode=4&page={{page}}&order_by={{order_by}}">
<span class="label label-staled {% if mode == 4 %}active{% endif %}">
Staled ({{counts.get(4, 0)}})
</span>
</a>
</div>
<div class="group">
Group by thread: <input type="checkbox" {% if order_by == "tid" %}checked{% endif %} onClick="javascript:window.location='?mode={{mode}}&page={{page}}&order_by={% if order_by == "tid" %}id{% else %}tid{% endif %}';" />
</div>
<div class="pagination">
Pages:
{% if page > 0 %}
<a href="?mode={{mode}}&page={{page - 1}}">
«
</a>
{% endif %}
<input type="text" size="1" name="page" value="{{page}}" />
{% if page < max_page %}
<a href="?mode={{mode}}&page={{page + 1}}">
»
</a>
{% endif %}
/ {{ max_page }}
</div>
</div>
<div class="filters order">
Order:
{% for order in ['id', 'created', 'modified', 'likes', 'dislikes'] %}
<a href="?mode={{mode}}&page={{page}}&order_by={{order}}&asc={{1 - asc}}">
<span class="label label-valid {% if order == order_by %}active{% endif %}">
{{ order }}
{% if order == order_by %}
{% if asc %} ↑ {% else %} ↓ {% endif %}
{% else %}
{% endif %}
</span>
</a>
{% endfor %}
</div>
</div>
<main>
{% set thread_id = "no_id" %}
{% for comment in comments %}
{% if order_by == "tid" %}
{% if thread_id != comment.tid %}
{% set thread_id = comment.tid %}
<h2 class="thread-title">{{comment.title}} (<a href="{{comment.uri}}">{{comment.uri}}</a>)</h2>
{% endif %}
{% endif %}
<div class='isso-comment' id='isso-{{comment.id}}'>
{% if conf.avatar %}
<div class='avatar'>
svg(data-hash='#{{comment.hash}}')
</div>
{% endif %}
<div class='text-wrapper'>
<div class='isso-comment-header' role='meta'>
{% if order_by != "tid" %}
<div>Thread: {{comment.title}} (<a href="{{comment.uri}}">{{comment.uri}}</a>)</div><br />
{% endif %}
{% if comment.author %}
<span class='author' id="isso-author-{{comment.id}}">{{comment.author}}</span>
{% else %}
<span class='author' id="isso-author-{{comment.id}}">Anonymous</span>
{% endif %}
{% if comment.email %}
(<span id="isso-email-{{comment.id}}">{{comment.email}}</span> <a href="mailto:{{comment.email}}" rel='nofollow' class='email'>mailto</a>)
{% else %}
<span id="isso-email-{{comment.id}}"></span>
{% endif %}
{% if comment.website %}
(<span id="isso-website-{{comment.id}}">{{comment.website}}</span> <a href="{{comment.website}}" rel='nofollow' class='website'>open</a>)
{% else %}
<span id="isso-website-{{comment.id}}"></span>
{% endif %}
<span class="spacer"> &bull;</span>
<time>{{comment.created | datetimeformat}}</time>
<span class='note'>
{% if comment.mode == 1 %}
<span class="label label-valid">Valid</span>
{% elif comment.mode == 2 %}
<span class="label label-pending">Pending</span>
{% elif comment.mode == 4 %}
<span class="label label-staled">Staled</span>
{% endif %}
</span>
</div>
<div class='text'>
{% if comment.mode == 4 %}
<strong>HIDDEN</strong>. Original text: <br />
{% endif %}
<div id="isso-text-{{comment.id}}">{{comment.text}}</div>
</div>
<div class='isso-comment-footer'>
{% if conf.votes and comment.likes - comment.dislikes != 0 %}
<span class='votes'>{{comment.likes - comment.dislikes}}</span>
{% endif %}
<span class='spacer'></span>
<a id="edit-btn-{{comment.id}}" class="edit" onClick="javascript:start_edit({{comment.id}})">Edit</a>
<a id="stop-edit-btn-{{comment.id}}" class="hidden edit" onClick="javascript:stop_edit({{comment.id}})">Cancel</a>
<a id="send-edit-btn-{{comment.id}}" class="hidden edit" onClick="javascript:send_edit({{comment.id}}, '{{comment.hash}}')">Send</a>
{% if comment.mode != 4 %}
<a class="delete"
onClick="javascript:delete_com({{comment.id}}, '{{comment.hash}}')">
Delete
</a>
{% endif %}
{% if comment.mode == 2 %}
<a class='validate'
onClick="javascript:validate_com({{comment.id}}, '{{comment.hash}}')">Validate</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</main>
</div>
</body>
</html>

30
isso/templates/login.html Normal file
View File

@ -0,0 +1,30 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="/css/isso.css" rel="stylesheet">
<link type="text/css" href="/css/admin.css" rel="stylesheet">
</head>
<body>
<div class="wrapper">
<div class="header">
<header>
<img class="logo" src="/img/isso.svg" alt="Wynaut by @veekun"/>
<div class="title">
<a href="./">
<h1>Isso</h1>
<h2>Administration</h2>
</a>
</div>
</header>
</div>
<main>
<div id="login">
Administration secured by password:
<form method="POST" action="/login">
<input type="password" name="password" />
</form>
</div>
</main>
</div>
</body>
</html>

View File

@ -5,9 +5,12 @@ from __future__ import division, unicode_literals
import pkg_resources import pkg_resources
werkzeug = pkg_resources.get_distribution("werkzeug") werkzeug = pkg_resources.get_distribution("werkzeug")
import json
import hashlib import hashlib
import json
import os
from datetime import datetime
from jinja2 import Environment, FileSystemLoader
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
@ -109,6 +112,19 @@ class JSONRequest(Request):
raise BadRequest('Unable to read JSON 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): class JSONResponse(Response):
def __init__(self, obj, *args, **kwargs): def __init__(self, obj, *args, **kwargs):

View File

@ -7,6 +7,7 @@ import cgi
import time import time
import functools import functools
from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature from itsdangerous import SignatureExpired, BadSignature
from werkzeug.http import dump_cookie from werkzeug.http import dump_cookie
@ -15,11 +16,13 @@ from werkzeug.utils import redirect
from werkzeug.routing import Rule from werkzeug.routing import Rule
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest, Forbidden, NotFound from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from werkzeug.contrib.securecookie import SecureCookie
from isso.compat import text_type as str from isso.compat import text_type as str
from isso import utils, local 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.views import requires
from isso.utils.hash import sha1 from isso.utils.hash import sha1
@ -91,12 +94,14 @@ class API(object):
('view', ('GET', '/id/<int:id>')), ('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')), ('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')), ('delete', ('DELETE', '/id/<int:id>')),
('moderate',('GET', '/id/<int:id>/<any(activate,delete):action>/<string:key>')), ('moderate',('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('moderate',('POST', '/id/<int:id>/<any(activate,delete):action>/<string:key>')), ('moderate',('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('like', ('POST', '/id/<int:id>/like')), ('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')), ('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')), ('demo', ('GET', '/demo')),
('preview', ('POST', '/preview')) ('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
] ]
def __init__(self, isso, hasher): def __init__(self, isso, hasher):
@ -502,7 +507,6 @@ class API(object):
Yo Yo
""" """
def moderate(self, environ, request, id, action, key): def moderate(self, environ, request, id, action, key):
try: try:
id = self.isso.unsign(key, max_age=2**32) id = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired): except (BadSignature, SignatureExpired):
@ -532,13 +536,21 @@ class API(object):
with self.isso.lock: with self.isso.lock:
self.comments.activate(id) self.comments.activate(id)
self.signal("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: else:
with self.isso.lock: with self.isso.lock:
self.comments.delete(id) self.comments.delete(id)
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8')) self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
self.signal("comments.delete", id) 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): def demo(self, env, req):
return redirect(get_current_url(env) + '/index.html') 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)

View File

@ -5,7 +5,7 @@ import sys
from setuptools import setup, find_packages 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): if (3, 0) <= sys.version_info < (3, 3):
raise SystemExit("Python 3.0, 3.1 and 3.2 are not supported") raise SystemExit("Python 3.0, 3.1 and 3.2 are not supported")

View File

@ -10,6 +10,7 @@ host = http://isso-dev.local/
max-age = 15m max-age = 15m
notify = stdout notify = stdout
log-file = /var/log/isso.log log-file = /var/log/isso.log
admin_password = strong_default_password_for_isso_admin
[moderation] [moderation]
enabled = false enabled = false

View File

@ -43,9 +43,12 @@ max-age = 15m
# moderated) and deletion links. # moderated) and deletion links.
notify = stdout notify = stdout
# Log console messages to file instead of standard out. # Log console messages to file instead of standard output.
log-file = log-file =
# Admin access password
admin_password = please_choose_a_strong_password
[moderation] [moderation]
# enable comment moderation queue. This option only affects new comments. # enable comment moderation queue. This option only affects new comments.