Merge pull request #1 from posativ/master

Up to date
pull/370/head
Yum 6 years ago committed by GitHub
commit fbcd453799
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,21 +1,32 @@
language: python
python: 2.7
env:
- TOX_ENV=py26
- TOX_ENV=py27
- TOX_ENV=py33
- TOX_ENV=py34
- TOX_ENV=squeeze
- TOX_ENV=wheezy
matrix:
allow_failures:
- env: TOX_ENV=squeeze
include:
- python: 2.6
env: TOX_ENV=py26
- python: 2.7
env: TOX_ENV=py27
- python: 3.3
env: TOX_ENV=py33
- python: 3.4
env: TOX_ENV=py34
- python: 3.5
env: TOX_ENV=py35
- python: 3.6
env: TOX_ENV=py36
- python: 2.6
env: TOX_ENV=squeeze
- python: 2.7
env: TOX_ENV=wheezy
install:
- pip install -U pip
- pip install tox
- pip install pyflakes
- sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm
script:
- tox -e $TOX_ENV
- python -m pyflakes.__main__ $(git ls-files | grep -E "^isso/.+.py$" | grep -v "^isso/compat.py")
notifications:
irc:
channels:

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

@ -34,9 +34,9 @@ init:
check:
@echo "Python 2.x"
-@python2 -m pyflakes $(ISSO_PY_SRC)
@python2 -m pyflakes $(filter-out isso/compat.py,$(ISSO_PY_SRC))
@echo "Python 3.x"
-@python3 -m pyflakes $(ISSO_PY_SRC)
@python3 -m pyflakes $(filter-out isso/compat.py,$(ISSO_PY_SRC))
isso/js/%.min.js: $(ISSO_JS_SRC) $(ISSO_CSS)
$(RJS) -o isso/js/build.$*.js out=$@

@ -0,0 +1,11 @@
{
"name": "isso",
"description": "a Disqus alternative",
"title": "isso API",
"order": ["Thread", "Comment"],
"template": {
"withCompare": false
}
}

@ -10,7 +10,8 @@
VERSION: '{{ release|e }}',
COLLAPSE_INDEX: false,
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
HAS_SOURCE: {{ has_source|lower }}
HAS_SOURCE: {{ has_source|lower }},
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
};
</script>
{%- for scriptfile in script_files %}

@ -171,7 +171,7 @@ html_static_path = ['_static']
# Additional templates that should be rendered to pages, maps page names to
# template names.
html_additional_pages = {"index": "docs/index.html"}
html_additional_pages = {"index": "index.html"}
# If false, no module index is generated.
html_domain_indices = False

@ -124,14 +124,3 @@ For example, the value `"-5,5"` will cause each `isso-comment` to be given one o
- `isso-vote-level-2` for scores of `5` and greater
These classes can then be used to customize the appearance of comments (eg. put a star on popular comments)
data-isso-id
------------
Set a custom thread id, defaults to current URI. This attribute needs
to be used with the data-title attribute in order to work.
If you use a comment counter, add this attribute to the link tag, too.
.. code-block:: html
<section data-title="Yay!" data-isso-id="test.abc" id="isso-thread"></section>

@ -23,11 +23,11 @@ Isso:
"mode": 1,
"hash": "4505c1eeda98",
"author": null,
"website": null
"website": null,
"created": 1387321261.572392,
"modified": null,
"likes": 3,
"dislikes": 0,
"dislikes": 0
}
id :
@ -70,7 +70,7 @@ modified :
List comments
-------------
List all publicely visible comments for thread `uri`:
List all publicly visible comments for thread `uri`:
.. code-block:: text

@ -213,7 +213,7 @@ Next, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer):
WSGIServer(application).run()
`Openshift <http://openshift.com>`__
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
With `Isso Openshift Deployment Kit`_, Isso can be installed on Open
Shift with just one click. Make sure you already have installed ``rhc``
(`instructions`_) and completed the setup.

@ -10,7 +10,7 @@ Debian/Ubuntu, Gentoo, Archlinux or Fedora, you can use
:local:
:depth: 1
.. _install-interludium:
.. _install-interludium:
Interludium: Python is not PHP
------------------------------
@ -216,6 +216,7 @@ don't use FastCGi or uWSGI:
- systemd (Isso + Gunicorn): https://github.com/jgraichen/debian-isso/blob/master/debian/isso.service
- SysVinit (Isso + Gunicorn): https://github.com/jgraichen/debian-isso/blob/master/debian/isso.init
- OpenBSD: https://gist.github.com/noqqe/7397719
- FreeBSD: https://gist.github.com/ckoepp/52f6f0262de04cee1b88ef4a441e276d
- Supervisor: https://github.com/posativ/isso/issues/47
If you're writing your own init script, you can utilize ``start-stop-daemon``

@ -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/')
}))
@ -220,7 +221,8 @@ def main():
imprt.add_argument("--empty-id", dest="empty_id", action="store_true",
help="workaround for weird Disqus XML exports, #135")
serve = subparser.add_parser("run", help="run server")
# run Isso as stand-alone server
subparser.add_parser("run", help="run server")
args = parser.parse_args()
conf = config.load(join(dist.location, dist.project_name, "defaults.ini"), args.conf)

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

@ -3,7 +3,7 @@
-moz-box-sizing: border-box;
box-sizing: border-box;
}
#isso-thread a {
#isso-thread .isso-comment-header a {
text-decoration: none;
}
@ -20,7 +20,7 @@
outline: 0;
}
#isso-thread .textarea.placeholder {
color: #AAA;
color: #757575;
}
.isso-comment {

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

@ -1,9 +1,9 @@
define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de",
"app/i18n/en", "app/i18n/fa", "app/i18n/fi", "app/i18n/fr",
"app/i18n/hr", "app/i18n/ru", "app/i18n/it", "app/i18n/eo",
"app/i18n/sv", "app/i18n/nl", "app/i18n/el_GR", "app/i18n/es",
"app/i18n/vi", "app/i18n/zh_CN"],
function(config, bg, cs, de, en, fa, fi, fr, hr, ru, it, eo, sv, nl, el, es, vi, zh) {
define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
"app/i18n/de", "app/i18n/en", "app/i18n/fa", "app/i18n/fi",
"app/i18n/fr", "app/i18n/hr", "app/i18n/hu", "app/i18n/ru", "app/i18n/it",
"app/i18n/eo", "app/i18n/sv", "app/i18n/nl", "app/i18n/el_GR",
"app/i18n/es", "app/i18n/vi", "app/i18n/zh_CN", "app/i18n/zh_CN", "app/i18n/zh_TW"],
function(config, bg, cs, da, de, en, fa, fi, fr, hr, hu, ru, it, eo, sv, nl, el, es, vi, zh, zh_CN, zh_TW) {
"use strict";
@ -11,6 +11,7 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de",
switch (lang) {
case "bg":
case "cs":
case "da":
case "de":
case "el":
case "en":
@ -19,11 +20,14 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de",
case "fa":
case "fi":
case "hr":
case "hu":
case "it":
case "sv":
case "nl":
case "vi":
case "zh":
case "zh_CN":
case "zh_TW":
return function(msgs, n) {
return msgs[n === 1 ? 0 : 1];
};
@ -55,7 +59,9 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de",
}
var catalogue = {
bg: bg,
cs: cs,
da: da,
de: de,
el: el,
en: en,
@ -66,11 +72,14 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de",
fr: fr,
it: it,
hr: hr,
hu: hu,
ru: ru,
sv: sv,
nl: nl,
vi: vi,
zh: zh
zh: zh_CN,
zh_CN: zh_CN,
zh_TW: zh_TW
};
var plural = pluralforms(lang);

@ -0,0 +1,30 @@
define({
"postbox-text": "Type Comment Here (at least 3 chars)",
"postbox-author": "Name (optional)",
"postbox-email": "E-mail (optional)",
"postbox-website": "Website (optional)",
"postbox-submit": "Submit",
"num-comments": "One Comment\n{{ n }} Comments",
"no-comments": "Ingen kommentarer endnu",
"comment-reply": "Svar",
"comment-edit": "Rediger",
"comment-save": "Gem",
"comment-delete": "Fjern",
"comment-confirm": "Bekræft",
"comment-close": "Luk",
"comment-cancel": "Annuller",
"comment-deleted": "Kommentar slettet.",
"comment-queued": "Kommentar i kø for moderation.",
"comment-anonymous": "Anonym",
"comment-hidden": "{{ n }} Skjult",
"date-now": "lige nu",
"date-minute": "et minut siden\n{{ n }} minutter siden",
"date-hour": "en time siden\n{{ n }} timer siden",
"date-day": "Igår\n{{ n }} dage siden",
"date-week": "sidste uge\n{{ n }} uger siden",
"date-month": "sidste måned\n{{ n }} måneder siden",
"date-year": "sidste år\n{{ n }} år siden"
});

@ -0,0 +1,27 @@
define({
"postbox-text": "Hozzászólást ide írd be (legalább 3 betűt)",
"postbox-author": "Név (nem kötelező)",
"postbox-email": "Email (nem kötelező)",
"postbox-website": "Website (nem kötelező)",
"postbox-submit": "Elküld",
"num-comments": "Egy hozzászólás\n{{ n }} hozzászólás",
"no-comments": "Eddig nincs hozzászólás",
"comment-reply": "Válasz",
"comment-edit": "Szerekesztés",
"comment-save": "Mentés",
"comment-delete": "Törlés",
"comment-confirm": "Megerősít",
"comment-close": "Bezár",
"comment-cancel": "Törlés",
"comment-deleted": "Hozzászólás törölve.",
"comment-queued": "A hozzászólást előbb ellenőrizzük.",
"comment-anonymous": "Névtelen",
"comment-hidden": "{{ n }} rejtve",
"date-now": "pillanatokkal ezelőtt",
"date-minute": "egy perce\n{{ n }} perce",
"date-hour": "egy órája\n{{ n }} órája",
"date-day": "tegnap\n{{ n }} napja",
"date-week": "múlt héten\n{{ n }} hete",
"date-month": "múlt hónapban\n{{ n }} hónapja",
"date-year": "tavaly\n{{ n }} éve"
});

@ -41,8 +41,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
// email is not optional if this config parameter is set
if (config["require-email"]) {
$("[name='email']", el).placeholder =
$("[name='email']", el).placeholder.replace(/ \(.*\)/, "");
$("[name='email']", el).setAttribute("placeholder",
$("[name='email']", el).getAttribute("placeholder").replace(/ \(.*\)/, ""));
}
// author is not optional if this config parameter is set

@ -47,7 +47,7 @@ define(["app/lib/promise", "app/config"], function(Q, config) {
}
Q.when(key, function(key) {
var hash = pad((parseInt(key, 16) % Math.pow(2, 18)).toString(2), 18),
var hash = pad((parseInt(key.substr(-16), 16) % Math.pow(2, 18)).toString(2), 18),
index = 0;
svg.setAttribute("data-hash", key);

@ -20,8 +20,8 @@ define(["app/i18n"], function(i18n) {
secs = 0;
}
var mins = Math.ceil(secs / 60), hours = Math.ceil(mins / 60),
days = Math.ceil(hours / 24);
var mins = Math.floor(secs / 60), hours = Math.floor(mins / 60),
days = Math.floor(hours / 24);
return secs <= 45 && i18n.translate("date-now") ||
secs <= 90 && i18n.pluralize("date-minute", 1) ||
@ -31,11 +31,11 @@ define(["app/i18n"], function(i18n) {
hours <= 36 && i18n.pluralize("date-day", 1) ||
days <= 5 && i18n.pluralize("date-day", days) ||
days <= 8 && i18n.pluralize("date-week", 1) ||
days <= 21 && i18n.pluralize("date-week", Math.ceil(days / 7)) ||
days <= 21 && i18n.pluralize("date-week", Math.floor(days / 7)) ||
days <= 45 && i18n.pluralize("date-month", 1) ||
days <= 345 && i18n.pluralize("date-month", Math.ceil(days / 30)) ||
days <= 345 && i18n.pluralize("date-month", Math.floor(days / 30)) ||
days <= 547 && i18n.pluralize("date-year", 1) ||
i18n.pluralize("date-year", Math.ceil(days / 365.25));
i18n.pluralize("date-year", Math.floor(days / 365.25));
};
var HTMLEntity = {

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

@ -65,6 +65,7 @@ class TestDBMigration(unittest.TestCase):
"supersecretkey")
def test_limit_nested_comments(self):
"""Transform previously A -> B -> C comment nesting to A -> B, A -> C"""
tree = {
1: None,
@ -97,7 +98,7 @@ class TestDBMigration(unittest.TestCase):
con.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", ("/", "Test"))
for (id, parent) in iteritems(tree):
con.execute("INSERT INTO comments ("
" tid, parent, created)"
" id, parent, created)"
"VALUEs (?, ?, ?)", (id, parent, id))
conf = config.new({
@ -108,15 +109,15 @@ class TestDBMigration(unittest.TestCase):
})
SQLite3(self.path, conf)
flattened = [
(1, None),
(2, None),
(3, 2),
(4, 2),
(5, 2),
(6, None),
(7, 2)
]
flattened = list(iteritems({
1: None,
2: None,
3: 2,
4: 2,
5: 2,
6: None,
7: 2
}))
with sqlite3.connect(self.path) as con:
rv = con.execute("SELECT id, parent FROM comments ORDER BY created").fetchall()

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

@ -44,7 +44,7 @@ class curl(object):
self.con = http(host, port, timeout=self.timeout)
try:
self.con.request(self.method, self.path, headers=self.headers)
except (httplib.HTTPException, socket.error) as e:
except (httplib.HTTPException, socket.error):
return None
try:

@ -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
@ -60,6 +63,12 @@ def xhr(func):
not forged (XHR is restricted by CORS separately).
"""
"""
@apiDefine csrf
@apiHeader {string="application/json"} Content-Type
The content type must be set to `application/json` to prevent CSRF attacks.
"""
def dec(self, env, req, *args, **kwargs):
if req.content_type and not req.content_type.startswith("application/json"):
@ -85,12 +94,14 @@ class API(object):
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('moderate',('GET', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
('moderate',('POST', '/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(edit,activate,delete):action>/<string:key>')),
('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')),
('preview', ('POST', '/preview'))
('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
]
def __init__(self, isso, hasher):
@ -141,6 +152,78 @@ class API(object):
return True, ""
# Common definitions for apidoc follow:
"""
@apiDefine plainParam
@apiParam {number=0,1} [plain]
Iff set to `1`, the plain text entered by the user will be returned in the comments `text` attribute (instead of the rendered markdown).
"""
"""
@apiDefine commentResponse
@apiSuccess {number} id
The comments id (assigned by the server).
@apiSuccess {number} parent
Id of the comment this comment is a reply to. `null` if this is a top-level-comment.
@apiSuccess {number=1,2,4} mode
The comments mode:
value | explanation
--- | ---
`1` | accepted: The comment was accepted by the server and is published.
`2` | in moderation queue: The comment was accepted by the server but awaits moderation.
`4` | deleted, but referenced: The comment was deleted on the server but is still referenced by replies.
@apiSuccess {string} author
The commentss authors name or `null`.
@apiSuccess {string} website
The comments authors website or `null`.
@apiSuccess {string} hash
A hash uniquely identifying the comments author.
@apiSuccess {number} created
UNIX timestamp of the time the comment was created (on the server).
@apiSuccess {number} modified
UNIX timestamp of the time the comment was last modified (on the server). `null` if the comment was not yet modified.
"""
"""
@api {post} /new create new
@apiGroup Comment
@apiDescription
Creates a new comment. The response will set a cookie on the requestor to enable them to later edit the comment.
@apiUse csrf
@apiParam {string} uri
The uri of the thread to create the comment on.
@apiParam {string} text
The comments raw text.
@apiParam {string} [author]
The comments authors name.
@apiParam {string} [email]
The comments authors email address.
@apiParam {string} [website]
The comments authors websites url.
@apiParam {number} [parent]
The parent comments id iff the new comment is a response to an existing comment.
@apiExample {curl} Create a reply to comment with id 15:
curl 'https://comments.example.com/new?uri=/thread/' -d '{"text": "Stop saying that! *isso*!", "author": "Max Rant", "email": "rant@example.com", "parent": 15}' -H 'Content-Type: application/json' -c cookie.txt
@apiUse commentResponse
@apiSuccessExample Success after the above request:
{
"website": null,
"author": "Max Rant",
"parent": 15,
"created": 1464940838.254393,
"text": "&lt;p&gt;Stop saying that! &lt;em&gt;isso&lt;/em&gt;!&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"hash": "e644f6ee43c0",
"id": 23,
"likes": 0
}
"""
@xhr
@requires(str, 'uri')
def new(self, environ, request, uri):
@ -217,6 +300,33 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
"""
@api {get} /id/:id view
@apiGroup Comment
@apiParam {number} id
The id of the comment to view.
@apiUse plainParam
@apiExample {curl} View the comment with id 4:
curl 'https://comments.example.com/id/4'
@apiUse commentResponse
@apiSuccessExample Example result:
{
"website": null,
"author": null,
"parent": null,
"created": 1464914341.312426,
"text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"id": 4,
"likes": 1
}
"""
def view(self, environ, request, id):
rv = self.comments.get(id)
@ -231,6 +341,41 @@ class API(object):
return JSON(rv, 200)
"""
@api {put} /id/:id edit
@apiGroup Comment
@apiDescription
Edit an existing comment. Editing a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details. Editing a comment will set a new edit cookie in the response.
@apiUse csrf
@apiParam {number} id
The id of the comment to edit.
@apiParam {string} text
A new (raw) text for the comment.
@apiParam {string} [author]
The modified comments authors name.
@apiParam {string} [webiste]
The modified comments authors website.
@apiExample {curl} Edit comment with id 23:
curl -X PUT 'https://comments.example.com/id/23' -d {"text": "I see your point. However, I still disagree.", "website": "maxrant.important.com"} -H 'Content-Type: application/json' -b cookie.txt
@apiUse commentResponse
@apiSuccessExample Example response:
{
"website": "maxrant.important.com",
"author": "Max Rant",
"parent": 15,
"created": 1464940838.254393,
"text": "&lt;p&gt;I see your point. However, I still disagree.&lt;/p&gt;",
"dislikes": 0,
"modified": 1464943439.073961,
"mode": 1,
"id": 23,
"likes": 0
}
"""
@xhr
def edit(self, environ, request, id):
@ -275,6 +420,21 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
"""
@api {delete} '/id/:id' delete
@apiGroup Comment
@apiDescription
Delte an existing comment. Deleting a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details.
@apiParam {number} id
Id of the comment to delete.
@apiExample {curl} Delete comment with id 14:
curl -X DELETE 'https://comments.example.com/id/14' -b cookie.txt
@apiSuccessExample Successful deletion returns null:
null
"""
@xhr
def delete(self, environ, request, id, key=None):
@ -312,8 +472,41 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
"""
@api {post} /id/:id/:action/key moderate
@apiGroup Comment
@apiDescription
Publish or delete a comment that is in the moderation queue (mode `2`). In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.
This endpoint can also be used with a `GET` request. In that case, a html page is returned that asks the user whether they are sure to perform the selected action. If they select yes, the query is repeated using `POST`.
@apiParam {number} id
The id of the comment to moderate.
@apiParam {string=activate,delete} action
`activate` to publish the comment (change its mode to `1`).
`delete` to delete the comment
@apiParam {string} key
The moderation key to authenticate the moderation.
@apiExample {curl} delete comment with id 13:
curl -X POST 'https://comments.example.com/id/13/delete/MTM.CjL6Fg.REIdVXa-whJS_x8ojQL4RrXnuF4'
@apiSuccessExample {html} Using GET:
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;script&gt;
if (confirm('Delete: Are you sure?')) {
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
}
&lt;/script&gt;
@apiSuccessExample Using POST:
Yo
"""
def moderate(self, environ, request, id, action, key):
try:
id = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
@ -343,14 +536,105 @@ 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)
"""
@api {get} / get comments
@apiGroup Thread
@apiDescription Queries the comments of a thread.
@apiParam {string} uri
The URI of thread to get the comments from.
@apiParam {number} [parent]
Return only comments that are children of the comment with the provided ID.
@apiUse plainParam
@apiParam {number} [limit]
The maximum number of returned top-level comments. Omit for unlimited results.
@apiParam {number} [nested_limit]
The maximum number of returned nested comments per commint. Omit for unlimited results.
@apiParam {number} [after]
Includes only comments were added after the provided UNIX timestamp.
@apiSuccess {number} total_replies
The number of replies if the `limit` parameter was not set. If `after` is set to `X`, this is the number of comments that were created after `X`. So setting `after` may change this value!
@apiSuccess {Object[]} replies
The list of comments. Each comment also has the `total_replies`, `replies`, `id` and `hidden_replies` properties to represent nested comments.
@apiSuccess {number} id
Id of the comment `replies` is the list of replies of. `null` for the list of toplevel comments.
@apiSuccess {number} hidden_replies
The number of comments that were ommited from the results because of the `limit` request parameter. Usually, this will be `total_replies` - `limit`.
@apiExample {curl} Get 2 comments with 5 responses:
curl 'https://comments.example.com/?uri=/thread/&limit=2&nested_limit=5'
@apiSuccessExample Example reponse:
{
"total_replies": 14,
"replies": [
{
"website": null,
"author": null,
"parent": null,
"created": 1464818460.732863,
"text": "&lt;p&gt;Hello, World!&lt;/p&gt;",
"total_replies": 1,
"hidden_replies": 0,
"dislikes": 2,
"modified": null,
"mode": 1,
"replies": [
{
"website": null,
"author": null,
"parent": 1,
"created": 1464818460.769638,
"text": "&lt;p&gt;Hi, now some Markdown: &lt;em&gt;Italic&lt;/em&gt;, &lt;strong&gt;bold&lt;/strong&gt;, &lt;code&gt;monospace&lt;/code&gt;.&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"hash": "2af4e1a6c96a",
"id": 2,
"likes": 2
}
],
"hash": "1cb6cc0309a2",
"id": 1,
"likes": 2
},
{
"website": null,
"author": null,
"parent": null,
"created": 1464818460.80574,
"text": "&lt;p&gt;Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium at commodi cum deserunt dolore, error fugiat harum incidunt, ipsa ipsum mollitia nam provident rerum sapiente suscipit tempora vitae? Est, qui?&lt;/p&gt;",
"total_replies": 0,
"hidden_replies": 0,
"dislikes": 0,
"modified": null,
"mode": 1,
"replies": [],
"hash": "1cb6cc0309a2",
"id": 3,
"likes": 0
},
"id": null,
"hidden_replies": 12
}
"""
@requires(str, 'uri')
def fetch(self, environ, request, uri):
@ -448,12 +732,60 @@ class API(object):
return fetched_list
"""
@apiDefine likeResponse
@apiSuccess {number} likes
The (new) number of likes on the comment.
@apiSuccess {number} dislikes
The (new) number of dislikes on the comment.
"""
"""
@api {post} /id/:id/like like
@apiGroup Comment
@apiDescription
Puts a like on a comment. The author of a comment cannot like its own comment.
@apiParam {number} id
The id of the comment to like.
@apiExample {curl} Like comment with id 23:
curl -X POST 'https://comments.example.com/id/23/like'
@apiUse likeResponse
@apiSuccessExample Example response
{
"likes": 5,
"dislikes": 2
}
"""
@xhr
def like(self, environ, request, id):
nv = self.comments.vote(True, id, utils.anonymize(str(request.remote_addr)))
return JSON(nv, 200)
"""
@api {post} /id/:id/dislike dislike
@apiGroup Comment
@apiDescription
Puts a dislike on a comment. The author of a comment cannot dislike its own comment.
@apiParam {number} id
The id of the comment to dislike.
@apiExample {curl} Dislike comment with id 23:
curl -X POST 'https://comments.example.com/id/23/dislike'
@apiUse likeResponse
@apiSuccessExample Example response
{
"likes": 4,
"dislikes": 3
}
"""
@xhr
def dislike(self, environ, request, id):
@ -471,6 +803,18 @@ class API(object):
return JSON(rv, 200)
"""
@api {post} /count count comments
@apiGroup Thread
@apiDescription
Counts the number of comments on multiple threads. The requestor provides a list of thread uris. The number of comments on each thread is returned as a list, in the same order as the threads were requested. The counts include comments that are reponses to comments.
@apiExample {curl} get the count of 5 threads:
curl 'https://comments.example.com/count' -d '["/blog/firstPost.html", "/blog/controversalPost.html", "/blog/howToCode.html", "/blog/boringPost.html", "/blog/isso.html"]
@apiSuccessExample Counts of 5 threads:
[2, 18, 4, 0, 3]
"""
def counts(self, environ, request):
data = request.get_json()
@ -490,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)

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

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

@ -10,7 +10,7 @@ dbpath = /tmp/comments.db
# required to dispatch multiple websites, not used otherwise.
name =
# Your website(s). If Isso is unable to connect to at least on site, you'll
# Your website(s). If Isso is unable to connect to at least one site, you'll
# get a warning during startup and comments are most likely non-functional.
#
# You'll need at least one host/website to run Isso. This is due to security
@ -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.
@ -103,7 +106,7 @@ security = starttls
# recipient address, e.g. your email address
to =
# ender address, e.g. "Foo Bar" <isso@example.tld>
# sender address, e.g. "Foo Bar" <isso@example.tld>
from =
# specify a timeout in seconds for blocking operations like the

@ -1,5 +1,5 @@
[tox]
envlist = py27,py33,py34,py35
envlist = py27,py33,py34,py35,py36
[testenv]
deps =

Loading…
Cancel
Save