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.
pull/359/head
Benoît Latinier 7 years ago committed by GitHub
commit d2b573a4d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -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/')
})) }))

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

@ -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`.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

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

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

@ -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):

@ -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)

@ -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")

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

@ -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.

Loading…
Cancel
Save