Add a basic admin interface (Fix issue #10)

Add a basic admin interface (Fix issue #10)

wip again

still wip

fix login page
This commit is contained in:
Benoît Latinier 2016-06-05 01:10:08 +02:00
parent d37b5bb030
commit 0a93c866ff
11 changed files with 427 additions and 7 deletions

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

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

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

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,58 @@ 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):
"""
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): 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

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

@ -0,0 +1,154 @@
<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 validate_com(com_id, hash) {
moderate(com_id, hash, "validate");
}
function delete_com(com_id, hash) {
moderate(com_id, hash, "delete");
}
</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}}">
<span class="label label-valid {% if mode == 1 %}active{% endif %}">
Valid ({{counts.get(1, 0)}})
</span>
</a>
<a href="?mode=2&page={{page}}">
<span class="label label-pending {% if mode == 2 %}active{% endif %}">
Pending ({{counts.get(2, 0)}})
</span>
</a>
<a href="?mode=4&page={{page}}">
<span class="label label-staled {% if mode == 4 %}active{% endif %}">
Staled ({{counts.get(4, 0)}})
</span>
</a>
</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 %}
</div>
</div>
</div>
<main>
{% for comment in comments %}
<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 comment.author %}
<span class='author'>{{comment.author}}</span>
{% else %}
<span class='author'>Anonymous</span>
{% endif %}
{% if comment.website %}
<a href="{{comment.website}}" rel='nofollow' class='website'>({{comment.website}})</a>
{% 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 %}
{{comment.text}}
</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>
{% 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
@ -90,7 +93,9 @@ class API(object):
('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):
@ -490,3 +495,41 @@ 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 = 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)

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.