Compare commits

..

1 Commits

Author SHA1 Message Date
Benoît Latinier fb23f4079d add: some docker config
6 years ago

@ -1,47 +0,0 @@
---
kind: pipeline
name: default
platform:
os: linux
arch: amd64
steps:
- name: publish
pull: default
image: plugins/docker:18.09
settings:
registry: https://registry.nixaid.com
repo: "registry.nixaid.com/${DRONE_REPO_NAMESPACE}/${DRONE_REPO_NAME}"
tags:
- latest
username:
from_secret: docker_username
password:
from_secret: docker_password
# storage_path: /drone/docker
# storage_driver: aufs
# ipv6: false
# debug: true
when:
branch:
- master
event:
- push
- tag
- name: notify
pull: default
image: drillster/drone-email:latest
settings:
from: "Drone CI <noreply@nixaid.com>"
host: mx.nixaid.com
port: 587
subject: "NIXAID Drone Pipeline {{#success build.status}}SUCCESS{{else}}FAILURE{{/success}} Notification"
when:
event:
- push
- tag
status:
- success
- failure

@ -1,85 +0,0 @@
# workspace:
# base: /workspace
# path: src/git.nixaid.com/arno/myapp/
#
# branches:
# - master
pipeline:
restore_cache:
image: drillster/drone-volume-cache:latest
restore: true
mount:
- /drone/docker
# Set the ``DRONE_VOLUME=/tmp/drone-cache:/cache`` drone-server variable,
# so you can benefit from the caching.
# Otherwise you will have to make this repository trusted in Drone and use
# the volumes as follows.
# volumes:
# - /tmp/drone-cache:/cache
# drone repo add arno/isso
# drone secret add/update --name docker_username --value arno --event push --event tag --event deployment arno/isso
# drone secret add/update --name docker_password --value "$(pass show vps/registry.nixaid.com | head -1)" --event push --event tag --event deployment arno/isso
publish:
image: plugins/docker:17.12
# repo: andrey01/${DRONE_REPO_NAME}
registry: registry.nixaid.com
repo: registry.nixaid.com/arno/${DRONE_REPO_NAME}
tags:
- latest
# - ${DRONE_COMMIT_SHA:0:7}
# group: docker
# dockerfile: Dockerfile
secrets: [docker_username, docker_password]
# Since we restore the docker image cache to /drone/docker
storage_path: /drone/docker
use_cache: true
when:
event: [push, tag]
branch: master
rebuild_cache:
image: drillster/drone-volume-cache:latest
rebuild: true
mount:
- /drone/docker
# Set the ``DRONE_VOLUME=/tmp/drone-cache:/cache`` drone-server variable,
# so you can benefit from the caching.
# Otherwise you will have to make this repository trusted in Drone and use
# the volumes as follows.
# volumes:
# - /tmp/drone-cache:/cache
# # ca_cert comes from /srv/data/registry/certs/ca.crt
# claircheck:
# # image: jmccann/drone-clair:1
# image: andrey01/drone-clair
# url: http://clair:6060
# secrets: [ docker_username, docker_password ]
# # ignore errors for now. This will work only in drone 0.9 https://github.com/drone/drone-runtime/commit/3e8bd99f60f4032226523320cd2b2321f9525159
# err_ignore: true
# scan_image: registry.nixaid.com/arno/${DRONE_REPO_NAME}:latest
# ca_cert: |
# -----BEGIN CERTIFICATE-----
# MIIBOjCB4KADAgECAgkAzhpbLWXa4H0wCgYIKoZIzj0EAwIwEDEOMAwGA1UEAwwF
# bXktQ0EwHhcNMTgwNzA5MjIzMTAzWhcNMjgwNzA2MjIzMTAzWjAQMQ4wDAYDVQQD
# DAVteS1DQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFIE8bTfQ76U5qG/Xgjw
# BbQU0oRJLYlRxBIWF9MTNSJr2LoaoyrU8jrcWQGRrfKPoVuwUJWp2tp5SJy0AHH7
# 4fijIzAhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgKkMAoGCCqGSM49
# BAMCA0kAMEYCIQCYbTbxRD2yX4LzGjh84fKPWPQM9ps8RE2nfwZjqdRUGgIhAOHb
# USigh6FzqEPk2jiaV3t1wNtChRWRfupTKG6CD345
# -----END CERTIFICATE-----
notify:
image: drillster/drone-email:latest
from: Drone CI <noreply@nixaid.com>
subject: NIXAID Drone Pipeline {{#success build.status}}SUCCESS{{else}}FAILURE{{/success}} Notification
host: mx.nixaid.com
port: 587
# username: arno
# secrets: [ email_username, email_password ]
# recipients: [ andrey.arapov@nixaid.com ]
when:
status: [success, failure] # changed
event: [push, tag]

2
.gitignore vendored

@ -3,7 +3,7 @@
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
*~
# *~
*.pyc
.Python

@ -1,22 +1,22 @@
language: python
matrix:
include:
- python: 2.7
env: TOX_ENV=py27
- python: 3.4
env: TOX_ENV=py34
- python: 3.5
env: TOX_ENV=py35
- python: 3.6
env: TOX_ENV=py36
- python: 3.7
dist: xenial
env: TOX_ENV=py37
- python: 3.8
dist: xenial
env: TOX_ENV=py38
install:
- pip install -U pip
- pip install flake8 tox
- sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm
script:
- tox -e $TOX_ENV
- make flakes
- IGNORE=E226,E241,E265,E402,E501,E704
- flake8 . --count --ignore=${IGNORE} --max-line-length=127 --show-source --statistics
notifications:
irc:
channels:

@ -1,75 +1,19 @@
Changelog for Isso
==================
0.12.3 (UNRELEASED)
0.10.7 (unreleased)
-------------------
- New "flags" option in the [markdown] section to customize Misaka's Markdown
HTML rendering. By default, no flags are set.
[markup]
flags = skip-html, escape, hard-wrap
Check docs/configuration/server.rst for more details.
0.12.2 (2019-01-21)
-------------------
- Revert use of labels instead of placeholders, since it breaks
mail notifications. #524
0.12.1 (2019-01-19)
-------------------
- Revert fix for duplicate slashes, as it prevents isso from
starting in some cases. #523
0.12.0 (2019-01-18)
-------------------
- Fix compatibility with new XML API.
- Don't enable admin interface with default password by default. #491
- Add support and documentation for "generic" imports.
- Remove potential duplicate slashes in URLs from
email links. #420
- Add data-isso-reply-notifications to attributes in configuration.
- Use default IP in imports if none is found. Fixes imports of some comments.
- embed: fix feed link creation on older browsers.
- Properly handle to field in mail notifications when using uWSGI spooler
- css: fix vertical alignment of notification checkbox
0.11.1 (2018-11-03)
-------------------
- Include pre-built minified JavaScript and CSS.
0.11.0 (2018-11-03)
-------------------
Bugs & features:
- Fix link in moderation mails if isso is setup on a sub-url (e.g. domain.tld/comments/)
- Add reply notifications
- Fix Chinese translation
- Add Danish translation
- Add Hungarian translation
- Add Persian translation
- Add admin interface
- Add links highlighting in comments
- Add apidoc
- Add rc.d script for FreeBSD
- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable
- Add preview button
- Add Atom feed at /feed?uri={thread-id}
- Add optionnal gravatar support
- Add nofollow noopener on links inside comments
- Add Dockerfile
- Upgraded to Misaka 2
- Some tests/travis/documentation improvements and fixes + pep8
Translations:
- Fix Chinese translation & typo in CJK
- Add Danish translation
- Add Hungarian translation
- Add Persian translation
- Improvement on german translation
- Some tests/travis/documentation improvements and fixes
0.10.6 (2016-09-22)
-------------------

@ -4,11 +4,6 @@
* Martin Zimmermann <info@posativ.org>
## Co-Maintainers
* Benoît Latinier @blatinier <benoit@latinier.fr>
* Jelmer Vernooij <jelmer@jelmer.uk>
## Contributors
@ -56,66 +51,12 @@ In chronological order:
* Added configuration to require email addresses (no validation)
* Fix Vagrantfile
* Benoît Latinier @blatinier <benoit@latinier.fr>
* Benoît Latinier <benoit@latinier.fr>
* Fix thread discovery
* Added mandatory author
* Added admin interface
* Ivan Pantic <ivanpantic82@gmail.com>
* Added vote levels
* Martin Schenck @schemar
* Improvement in the german translation
* @cclauss
* Pep8 and drop of legacy supports (old python & debian version tested in travis)
* Make travis use pyflakes
* Lucas Cimon @Lucas-C
* Added the possibility to define CORS origins through ISSO_CORS_ORIGIN environment variable
* Fix a bug with <a> in <svg>
* Fixing likes counter of replies not being displayed
* Adding contrib/dump_comments.py
* Adding a [server] proxy-fix-enable-x-prefix configuration option
* Using .access_route instead of .remote_addr to take into account HTTP_X_FORWARDED_FOR header
* Yuchen Pei @ycpei
* Fix link in moderation emails when isso is installed in a sub URL
* @Rocket1184
* Fix typo in CJK translations
* @vincentbernat
* Added documentation about data-isso-id attribute (overriding the standard isso-thread-id)
* Added multi-staged Dockerfile
* Added atom feed
* Added a nofollow/noopener on links inside comments to protect against bots
* Added a preview using the existing preview endpoint
* @p-vitt & @M4a1x
* Documentation on troubleshooting for uberspace users
* Facundo Batista <facundo@taniquetil.com.ar>
* Added a generic way to migrate from a json file
* @benjhess
* Optionnal gravatar support
* Steffen Prince @sprin
* Upgrade to Misaka 2
* Rocka <i@rocka.me>
* Implementation and documentation about async comment loading
* Pelle Nilsson @pellenilsson
* Reply notifications
* Craig P Hicks @craigphicks
* Fix sub urls configurations on admin interface
* Chris Warrick @Kwpolska
* Update Polish translation
* Redirect to comment after moderation
* [Your name or handle] <[email or website]>
* [Brief summary of your changes]

@ -1,34 +1,15 @@
# First, compile JS stuff
FROM node:dubnium-buster
WORKDIR /src/
COPY . .
RUN npm install -g requirejs uglify-js jade bower \
&& make init js
FROM python:3.6
ARG ISSO_VER=0.10.6
# Second, create virtualenv
FROM python:3.8-buster
WORKDIR /src/
COPY --from=0 /src .
RUN python3 -m venv /isso \
&& . /isso/bin/activate \
&& pip3 install --no-cache-dir --upgrade pip \
&& pip3 install --no-cache-dir gunicorn cffi flask \
&& python setup.py install \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN apt-get update && apt-get install -y python-dev \
libffi-dev \
sqlite3 \
openssl \
&& pip install --no-cache "isso==${ISSO_VER}" \
&& rm -rf /tmp/*
# Third, create final repository
FROM python:3.8-slim-buster
WORKDIR /isso/
COPY --from=1 /isso .
EXPOSE 8081
# Configuration
VOLUME /db /config
EXPOSE 8080
ENV ISSO_SETTINGS /config/isso.cfg
CMD ["/isso/bin/gunicorn", "-b", "0.0.0.0:8080", "-w", "4", "--preload", "isso.run", "--worker-tmp-dir", "/dev/shm"]
# Example of use:
#
# docker build -t isso .
# docker run -it --rm -v /opt/isso:/config -v /opt/isso:/db -v $PWD:$PWD isso /isso/bin/isso -c \$ISSO_SETTINGS import disqus.xml
# docker run -d --rm --name isso -p 8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso
CMD ["isso", "-c", "/config/isso.conf", "run"]

@ -7,12 +7,10 @@ include isso/js/embed.min.js
include isso/js/embed.dev.js
include isso/js/count.min.js
include isso/js/count.dev.js
include isso/js/admin.js
include isso/defaults.ini
include isso/templates/admin.html
include isso/templates/disabled.html
include isso/templates/login.html
include isso/css/admin.css
include isso/css/isso.css

@ -1,5 +1,3 @@
# INSTALLATION: pip install sphinx && npm install --global node-sass
ISSO_JS_SRC := $(shell find isso/js/app -type f) \
$(shell ls isso/js/*.js | grep -vE "(min|dev)") \
isso/js/lib/requirejs-jade/jade.js
@ -29,15 +27,16 @@ DOCS_HTML_DST := docs/_build/html
RJS = r.js
SASS = node-sass
all: man js site
init:
(cd isso/js; bower --allow-root install almond requirejs requirejs-text jade)
flakes:
flake8 . --count --max-line-length=127 --show-source --statistics
check:
@echo "Python 2.x"
@python2 -m pyflakes $(filter-out isso/compat.py,$(ISSO_PY_SRC))
@echo "Python 3.x"
@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=$@
@ -54,7 +53,7 @@ man: $(DOCS_RST_SRC)
mv man/isso.conf.5 man/man5/isso.conf.5
${DOCS_CSS_DST}: $(DOCS_CSS_SRC) $(DOCS_CSS_DEP)
$(SASS) --no-cache $(DOCS_CSS_SRC) $@
scss --no-cache $(DOCS_CSS_SRC) $@
${DOCS_HTML_DST}: $(DOCS_RST_SRC) $(DOCS_CSS_DST)
sphinx-build -b dirhtml docs/ $@
@ -65,7 +64,7 @@ coverage: $(ISSO_PY_SRC)
nosetests --with-doctest --with-coverage --cover-package=isso --cover-html isso/
test: $($ISSO_PY_SRC)
python3 setup.py nosetests
python setup.py nosetests
clean:
rm -f $(DOCS_MAN_DST) $(DOCS_CSS_DST) $(ISSO_JS_DST)

@ -1,5 +1,3 @@
[![Build Status](https://drone.nixaid.com/api/badges/arno/isso/status.svg)](https://drone.nixaid.com/arno/isso)
Isso a commenting server similar to Disqus
============================================

@ -1,128 +0,0 @@
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2020 Lucas Cimon.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""Dump isso comments as text
The script can be run like this:
contrib/dump_comments.py .../path/to/isso.db --sort-by-last-reply
To get a list of all available options:
contrib/dump_comments.py --help
By installing the optional colorama dependency, you'll get a colored output.
An example of output can be found at https://github.com/posativ/isso/issues/634
"""
import argparse
import sqlite3
from collections import defaultdict, namedtuple
from datetime import date
from textwrap import indent
class ColorFallback():
__getattr__ = lambda self, name: '' # noqa: E731
try:
from colorama import Fore, Style, init
init() # needed for Windows
except ImportError: # fallback so that the imported classes always exist
Fore = Style = ColorFallback()
Comment = namedtuple('Comment', ('uri', 'id', 'parent', 'created', 'text', 'author', 'email', 'website', 'likes', 'dislikes', 'replies'))
INDENT = ' '
QUERY = 'SELECT uri, comments.id, parent, created, text, author, email, website, likes, dislikes FROM comments INNER JOIN threads on comments.tid = threads.id'
def main():
args = parse_args()
if not args.colors:
global Fore, Style
Fore = Style = ColorFallback()
db = sqlite3.connect(args.db_path)
comments_per_uri = defaultdict(list)
for result in db.execute(QUERY).fetchall():
comment = Comment(*result, replies=[])
comments_per_uri[comment.uri].append(comment)
root_comments_per_sort_date = {}
for comments in comments_per_uri.values():
comments_per_id = {comment.id: comment for comment in comments}
root_comments, sort_date = [], None
for comment in comments:
if comment.parent: # == this is a "reply" comment
comments_per_id[comment.parent].replies.append(comment)
if args.sort_by_last_reply and (sort_date is None or comment.created > sort_date):
sort_date = comment.created
else:
root_comments.append(comment)
if sort_date is None or comment.created > sort_date:
sort_date = comment.created
root_comments_per_sort_date[sort_date] = root_comments
for _, root_comments in sorted(root_comments_per_sort_date.items(), key=lambda pair: pair[0]):
print(Fore.MAGENTA + args.url_prefix + root_comments[0].uri + Fore.RESET)
for comment in root_comments:
print_comment(INDENT, comment)
for comment in comment.replies:
print_comment(INDENT * 2, comment)
print()
def print_comment(prefix, comment):
author = comment.author or 'Anonymous'
email = comment.email or ''
website = comment.website or ''
when = date.fromtimestamp(comment.created)
popularity = ''
if comment.likes:
popularity = '+{.likes}'.format(comment)
if comment.dislikes:
if popularity:
popularity += '/'
popularity = '-{.dislikes}'.format(comment)
print(prefix + '{Style.BRIGHT}{author}{Style.RESET_ALL} {Style.DIM}- {email} {website}{Style.RESET_ALL} {when} {Style.DIM}{popularity}{Style.RESET_ALL}'.format(Style=Style, **locals()))
print(indent(comment.text, prefix))
def parse_args():
parser = argparse.ArgumentParser(description='Dump all Isso comments in chronological order, grouped by replies',
formatter_class=ArgparseHelpFormatter)
parser.add_argument('db_path', help='File path to Isso Sqlite DB')
parser.add_argument('--sort-by-last-reply', action='store_true', help='By default comments are sorted by "parent" comment date, this sort comments based on the last replies')
parser.add_argument('--url-prefix', default='', help='Optional domain name to prefix to pages URLs')
parser.add_argument('--no-colors', action='store_false', dest='colors', default=True, help='Disabled colored output')
return parser.parse_args()
class ArgparseHelpFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
pass
if __name__ == '__main__':
main()

@ -1,123 +0,0 @@
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""Comment importer from Blogger
This python script can convert comments posted to a Blogger-powered blog to a
JSON file with can then be imported into Isso (by following the procedure
explained in docs/docs/extras/advanced-migration.rst.
The script can be run like this:
python import_blogger.py -p 'http://myblog.com/' blogger.xml out.json
where `blogger.xml` is a dump of the blog produced by the Blogger platform, and
the URL following the `-p` option is a prefix that will be applied to all post
URLs: the original host will be stripped and the path will be appended to the
string you specify here (this can be useful in the case that your blog moved to
a different domain, subdomain, or just into a new directory).
The `out.json` file is the file which will be generated by this tool, and which
can then be fed into isso:
isso -c /path/to/isso.cfg import -t generic out.json
"""
from __future__ import unicode_literals
import json
import feedparser
import time
from urllib.parse import urlparse
class Post:
def __init__(self, url):
self.url = url
self.title = None
self.comments = []
def add_comment(self, comment):
comment['id'] = len(self.comments) + 1
self.comments.append(comment)
def encode_post(post):
ret = {}
ret['id'] = post.url
ret['title'] = post.title
ret['comments'] = post.comments
return ret
class ImportBlogger:
TYPE_COMMENT = 'http://schemas.google.com/blogger/2008/kind#comment'
TYPE_POST = 'http://schemas.google.com/blogger/2008/kind#post'
def __init__(self, filename_in, filename_out, prefix):
self.channel = feedparser.parse(filename_in)
self.filename_out = filename_out
self.prefix = prefix
def run(self):
self.posts = {}
for item in self.channel.entries:
terms = [tag.term for tag in item.tags]
if not terms:
continue
if terms[0] == self.TYPE_COMMENT:
post = self.ensure_post(item)
post.add_comment(self.process_comment(item))
elif terms[0] == self.TYPE_POST:
self.process_post(item)
data = [encode_post(p) for p in self.posts.values() if p.comments]
with open(self.filename_out, 'w') as fp:
json.dump(data, fp, indent=2)
def process_post(self, item):
pid = self.post_id(item)
if pid in self.posts:
post = self.posts[pid]
else:
post = Post(pid)
self.posts[pid] = post
post.title = item.title
def ensure_post(self, item):
pid = self.post_id(item)
post = self.posts.get(pid, None)
if not post:
post = Post(pid)
self.posts[pid] = post
return post
def process_comment(self, item):
comment = {}
comment['author'] = item.author_detail.name
comment['email'] = item.author_detail.email
comment['website'] = item.author_detail.get('href', '')
t = time.strftime('%Y-%m-%d %H:%M:%S', item.published_parsed)
comment['created'] = t
comment['text'] = item.content[0].value
comment['remote_addr'] = '127.0.0.1'
return comment
def post_id(self, item):
u = urlparse(item.link)
return self.prefix + u.path
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(
description='Convert comments from blogger.com')
parser.add_argument('input', help='input file')
parser.add_argument('output', help='output file')
parser.add_argument('-p', dest='prefix',
help='prefix to be added to paths (ID)',
type=str, default='')
args = parser.parse_args()
importer = ImportBlogger(args.input, args.output, args.prefix)
importer.run()

@ -0,0 +1,13 @@
version: '2.0'
services:
isso:
build: ./
environment:
- GID=1000
- UID=1000
volumes:
- /mnt/docker/isso/config:/config
- /mnt/docker/isso/db:/db
ports:
- 8081:8081

@ -28,5 +28,4 @@
{{ doc("docs/extras/deployment", "Deployment") }}
{{ doc("docs/extras/advanced-integration", "Advanced Integration") }}
{{ doc("docs/extras/api", "API") }}
{{ doc("docs/extras/contribs", "Community tools") }}
</ul>

@ -26,7 +26,7 @@ except pkg_resources.DistributionNotFound:
dist = type("I'm a Version", (object, ), {})
with io.open(join(dirname(__file__), "../setup.py")) as fp:
for line in fp:
m = re.match("\\s*version='([^']+)'\\s*", line)
m = re.match("\s*version='([^']+)'\s*", line)
if m:
dist.version = m.group(1)
break

@ -59,3 +59,5 @@ definitely need help:
- delete or activate comments matching a filter (e.g. name, email, ip address)
- close threads and remove threads completely
- edit comments

@ -7,13 +7,11 @@ preferably in the script tag which embeds the JS:
.. code-block:: html
<script data-isso="/prefix/"
data-isso-id="thread-id"
data-isso-css="true"
data-isso-lang="ru"
data-isso-reply-to-self="false"
data-isso-require-author="false"
data-isso-require-email="false"
data-isso-reply-notifications="false"
data-isso-max-comments-top="10"
data-isso-max-comments-nested="5"
data-isso-reveal-on-click="5"
@ -21,25 +19,15 @@ preferably in the script tag which embeds the JS:
data-isso-avatar-bg="#f0f0f0"
data-isso-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
data-isso-vote="true"
data-isso-vote-levels=""
data-isso-feed="false"
data-vote-levels=""
src="/prefix/js/embed.js"></script>
Furthermore you can override the automatic title detection inside
the embed tag, as well as the thread ID, e.g.:
the embed tag, e.g.:
.. code-block:: html
<section id="isso-thread" data-title="Foo!" data-isso-id="/path/to/resource"></section>
Additionally, you can override any translation string for any language by adding
a ``data-isso-`` attribute that is equal to the translation key (found `here`__) with
``-text-[lang]`` appended to it. So, for example, if you want to override the
english translation of the ``postbox-notification`` message, you could add:
``data-isso-postbox-notification-text-en="Select to be notified of replies to your comment"``
.. __: https://github.com/posativ/isso/blob/master/isso/js/app/i18n/en.js
<section id="isso-thread" data-title="Foo!"></section>
data-isso
---------
@ -87,11 +75,6 @@ data-isso-require-email
Set to `true` when spam guard is configured with `require-email = true`.
data-isso-reply-notifications
-----------------------------
Set to `true` when reply notifications is configured with `reply-notifications = true`.
data-isso-max-comments-top and data-isso-max-comments-nested
------------------------------------------------------------
@ -123,14 +106,6 @@ scheme is based in `this color palette <http://colrd.com/palette/19308/>`_.
Multiple colors must be separated by space. If you use less than eight colors
and not a multiple of 2, the color distribution is not even.
data-isso-gravatar
------------------
Uses gravatar images instead of generating svg images. You have to set
"data-isso-avatar" to **false** when you want to use this. Otherwise
both the gravatar and avatar svg image will show up. Please also set
option "gravatar" to **true** in the server configuration...
data-isso-vote
--------------
@ -149,10 +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-feed
--------------
Enable or disable the addition of a link to the feed for the comment
thread. The link will only be valid if the appropriate setting, in
``[rss]`` section, is also enabled server-side.

@ -55,7 +55,7 @@ name
not used otherwise.
host
Your website(s). If Isso is unable to connect to at least one site, you'll
Your website(s). If Isso is unable to connect to at least on 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
@ -88,33 +88,9 @@ notify
Send notifications via SMTP on new comments with activation (if
moderated) and deletion links.
reply-notifications
Allow users to request E-mail notifications for replies to their post.
It is highly recommended to also turn on moderation when enabling this
setting, as Isso can otherwise be easily exploited for sending spam.
Do not forget to configure the client accordingly.
log-file
Log console messages to file instead of standard out.
gravatar
When set to ``true`` this will add the property "gravatar_image"
containing the link to a gravatar image to every comment. If a comment
does not contain an email address, gravatar will render a random icon.
This is only true when using the default value for "gravatar-url"
which contains the query string param ``d=identicon`` ...
gravatar-url
Url for gravatar images. The "{}" is where the email hash will be placed.
Defaults to "https://www.gravatar.com/avatar/{}?d=identicon"
latest-enabled
If True it will enable the ``/latest`` endpoint. Optional, defaults
to False.
.. _CORS: https://developer.mozilla.org/en/docs/HTTP/Access_control_CORS
@ -128,21 +104,13 @@ Enable moderation queue and handling of comments still in moderation queue
[moderation]
enabled = false
approve-if-email-previously-approved = false
purge-after = 30d
enabled
enable comment moderation queue. This option only affects new comments.
Comments in moderation queue are not visible to other users until you
Comments in modertion queue are not visible to other users until you
activate them.
approve-if-email-previously-approved
automatically approve comments by an email address if that address has
had a comment approved within the last 6 months. No ownership verification
is done on the entered email address. This means that if someone is able
to guess correctly the email address used by a previously approved author,
they will be able to have their new comment auto-approved.
purge-after
remove unprocessed comments in moderation queue after given time.
@ -176,12 +144,6 @@ listen
Does not apply for `uWSGI`.
public-endpoint
public URL that Isso is accessed from by end users. Should always be
a http:// or https:// absolute address. If left blank, automatic
detection is attempted. Normally only needs to be specified if
different than the `listen` setting.
reload
reload application, when the source code has changed. Useful for
development. Only works with the internal webserver.
@ -190,13 +152,6 @@ profile
show 10 most time consuming function in Isso after each request. Do
not use in production.
trusted-proxies
an optional list of reverse proxies IPs behind which you have deployed
your Isso web service (e.g. `127.0.0.1`).
This allow for proper remote address resolution based on a
`X-Forwarded-For` HTTP header, which is important for the mechanism
forbiding several comment votes coming from the same subnet.
.. _configure-smtp:
SMTP
@ -281,19 +236,19 @@ reply-to-self
the comment. After the editing timeframe is gone, commenters can reply to
their own comments anyways.
Do not forget to configure the `client <client>`_ accordingly
Do not forget to configure the client.
require-author
force commenters to enter a value into the author field. No validation is
performed on the provided value.
Do not forget to configure the `client <client>`_ accordingly.
Do not forget to configure the client accordingly.
require-email
force commenters to enter a value into the email field. No validation is
performed on the provided value.
Do not forget to configure the `client <client>`_ accordingly.
Do not forget to configure the client.
Markup
------
@ -305,20 +260,12 @@ supported, but new languages are relatively easy to add.
[markup]
options = strikethrough, superscript, autolink
flags = skip-html, escape, hard-wrap
allowed-elements =
allowed-attributes =
options
`Misaka-specific Markdown extensions <https://misaka.61924.nl/#api>`_, all
extension flags can be used there, separated by comma, either by their name
or as `EXT_`_.
flags
`Misaka-specific HTML rendering flags
<https://misaka.61924.nl/#html-render-flags>`_, all html rendering flags
can be used here, separated by comma, either by their name or as `HTML_`_.
Per Misaka's defaults, no flags are set.
`Misaka-specific Markdown extensions <http://misaka.61924.nl/api/>`_, all
flags starting with `EXT_` can be used there, separated by comma.
allowed-elements
Additional HTML tags to allow in the generated output, comma-separated. By
@ -361,45 +308,6 @@ algorithm
Arguments have to be in that order, but can be reduced to `pbkdf2:4096`
for example to override the iterations only.
.. _configure-rss:
RSS
---
Isso can provide an Atom feed for each comment thread. Users can use
them to subscribe to comments and be notified of changes. Atom feeds
are enabled as soon as there is a base URL defined in this section.
.. code-block:: ini
[rss]
base =
limit = 100
base
base URL to use to build complete URI to pages (by appending the URI from Isso)
limit
number of most recent comments to return for a thread
Admin
-----
Isso has an optional web administration interface that can be used to moderate
comments. The interface is available under ``/admin`` on your isso URL.
.. code-block:: ini
[admin]
enabled = true
password = secret
enabled
whether to enable the admin interface
password
the plain text password to use for logging into the administration interface
Appendum
--------
@ -412,18 +320,3 @@ Timedelta
You can add different types: `1m30s` equals to 90 seconds, `3h45m12s`
equals to 3 hours, 45 minutes and 12 seconds (12512 seconds).
Environment variables
---------------------
.. _environment-variables:
Isso also support configuration through some environment variables:
ISSO_CORS_ORIGIN
By default, `isso` will use the `Host` or else the `Referrer` HTTP header
of the request to defines a CORS `Access-Control-Allow-Origin` HTTP header
in the response.
This environent variable allows you to define a broader fixed value,
in order for example to share a single Isso instance among serveral of your
subdomains : `ISSO_CORS_ORIGIN=*.example.test`

@ -23,34 +23,3 @@ Now, either include `count.min.js` if you want to show only the comment count
You can have as many comments counters as you want in a page, and they will be
merged into a single `GET` request.
Asynchronous comments loading
-----------------------------
Isso will automatically fetch comments after `DOMContentLoaded` event. However
in the case where your website is creating content dynamically (eg. via ajax),
you need to re-fetch comment thread manually. Here is how you can re-fetch the
comment thread:
.. code-block:: js
window.Isso.fetchComments()
It will delete all comments under the thread but not the PostBox, fetch
comments with `data-isso-id` attribute of the element `section#isso-thread` (if
that attribute does not exist, fallback to `window.location.pathname`), then
fill comments into the thread. In other words, you should change `data-isso-id`
attribute of the element `section#isso-thread` (or modify the pathname with
`location.pushState`) before you can get new comments. And the thread element
itself should *NOT* be touched or removed.
If you removed the `section#isso-thread` element, just create another element
with same TagName and ID in which you wish comments to be placed, then call the
`init` method of `Isso`:
.. code-block:: js
window.Isso.init()
Then Isso will initialize the comment section and fetch comments, as if the page
was loaded.

@ -1,70 +0,0 @@
Advanced Migration
==================
In quickstart we saw you can import comments from Disqus or WordPress. But there
are a many other comments system and you could be using one of them.
Isso provides a way to import such comments, however it's up to you to to:
- dump comments
- fit the data to the following JSON format::
A list of threads, each item being a dict with the following data:
- id: a text representing the unique thread id (note: by default isso
associates this ID to the article URL, but it can be changed on
client side with "data-isso-id" - see :doc:`client configuration <../configuration/client>` )
- title: the title of the thread
- comments: the list of comments
Each item in that list of comments is a dict with the following data:
- id: an integer with the unique id of the comment inside the thread
(it can be repeated among different threads); this will be used to
order the comment inside the thread
- author: the author's name
- email: the author's email
- website: the author's website
- remote_addr: the author's IP
- created: a timestamp, in the format "%Y-%m-%d %H:%M:%S"
Example:
.. code-block:: json
[
{
"id": "/blog/article1",
"title": "First article!",
"comments": [
{
"author": "James",
"created": "2018-11-28 17:24:23",
"email": "email@mail.com",
"id": "1",
"remote_addr": "127.0.0.1",
"text": "Great article!",
"website": "http://fefzfzef.frzr"
},
{
"author": "Harold",
"created": "2018-11-28 17:58:03",
"email": "email2@mail.com",
"id": "2",
"remote_addr": "",
"text": "I hated it...",
"website": ""
}
]
}
]
Keep in mind that isso expects to have an array, so keep the opening and ending square bracket even if you have only one article thread!
Next you can import you json dump:
.. code-block:: sh
~> isso -c /path/to/isso.cfg import -t generic comment-dump.json
[100%] 53 threads, 192 comments

@ -83,19 +83,6 @@ plain :
pass plain=1 to get the raw comment text, defaults to 0.
Get the latest N comments for all threads:
.. code-block:: text
GET /latest?limit=N
The N parameter limits how many of the latest comments to retrieve; it's
mandatory, and must be an integer greater than 0.
This endpoint needs to be enabled in the configuration (see the
``latest-enabled`` option in the ``general`` section).
Create comment
--------------
@ -198,16 +185,3 @@ uri :
returns an integer
Get Atom feed
-------------
Get an Atom feed of comments for thread `uri`:
.. code-block:: text
GET /feed?uri=%2Fhello-world%2F
uri :
URI to get comments for, required.
Returns an XML document as the Atom feed.

@ -1,20 +0,0 @@
Community tools
===============
Utility scripts
---------------
Some utility scripts have been developed by isso users.
They are stored in the `GitHub contrib/ directory
<https://github.com/posativ/isso/tree/master/contrib>`_ :
* `dump_comments.py` : dump isso comments as text, optionally with color
* `import_blogger.py` : comment importer from Blogger
Related projects
----------------
* `wonderfall/isso Docker image <https://github.com/Wonderfall/docker-isso>`
* `a grav plugin to integrate isso comments <https://github.com/Sommerregen/grav-plugin-jscomments>`
* `a Pelican theme supporting isso comments <https://github.com/Lucas-C/pelican-mg>`

@ -4,7 +4,7 @@ Deployment
Isso ships with a built-in web server, which is useful for the initial setup
and may be used in production for low-traffic sites (up to 20 requests per
second). Running a "real" WSGI server supports nice things such as UNIX domain
sockets, daemonization and solid HTTP handler. WSGI servers are more stable, secure
sockets, daemonization and solid HTTP handler while being more stable, secure
and web-scale than the built-in web server.
* gevent_, coroutine-based network library
@ -98,45 +98,28 @@ To execute Isso, use a command similar to:
`mod_wsgi <https://code.google.com/p/modwsgi/>`__
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
First, create a startup script, called `isso.wsgi`. If Isso is in your system module
search path, then the script is quite simple. This script is included in the
isso distribution as `run.py`:
First, create a startup script, called `isso.wsgi`. If Isso is in your system module
search path, then the script is quite simple:
.. code-block:: python
from __future__ import unicode_literals
import os
from isso import make_app
from isso import dist, config
from isso.core import Config
application = make_app(
config.load(
os.path.join(dist.location, dist.project_name, "defaults.ini"),
"/path/to/isso.cfg"),
multiprocessing=True)
application = make_app(Config.load("/path/to/isso.cfg"))
If you have installed Isso in a virtual environment, then you will have to add the path
of the virtualenv to the site-specific paths of Python:
.. code-block:: python
from __future__ import unicode_literals
import site
site.addsitedir("/path/to/isso_virtualenv")
import os
from isso import make_app
from isso import dist, config
from isso.core import Config
application = make_app(
config.load(
os.path.join(dist.location, dist.project_name, "defaults.ini"),
"/path/to/isso.cfg",
multiprocessing=True)
application = make_app(Config.load("/path/to/isso.cfg"))
Using the aforementioned script will load system modules when available and modules
from the virtualenv otherwise. Should you want the opposite behavior, where modules from
@ -144,39 +127,32 @@ the virtualenv have priority over system modules, the following script does the
.. code-block:: python
from __future__ import unicode_literals
import os
import site
import sys
import site
import sys
# Remember original sys.path.
prev_sys_path = list(sys.path)
prev_sys_path = list(sys.path)
# Add the new site-packages directory.
site.addsitedir("/path/to/isso_virtualenv")
# Reorder sys.path so new directories at the front.
new_sys_path = []
for item in list(sys.path):
if item not in prev_sys_path:
new_sys_path.append(item)
sys.path.remove(item)
sys.path[:0] = new_sys_path
new_sys_path = []
for item in list(sys.path):
if item not in prev_sys_path:
new_sys_path.append(item)
sys.path.remove(item)
sys.path[:0] = new_sys_path
from isso import make_app
from isso import dist, config
from isso.core import Config
application = make_app(
config.load(
os.path.join(dist.location, dist.project_name, "defaults.ini"),
"/path/to/isso.cfg",
multiprocessing=True)
application = make_app(Config.load("/path/to/isso.cfg"))
The last two scripts are based on those given by
The last two scripts are based on those given by
`mod_wsgi documentation <https://code.google.com/p/modwsgi/wiki/VirtualEnvironments>`_.
The Apache configuration will then be similar to the following:
The Apache configuration will then be similar to the following:
.. code-block:: apache
@ -187,19 +163,15 @@ The Apache configuration will then be similar to the following:
WSGIScriptAlias /mounted_isso_path /path/to/isso.wsgi
</VirtualHost>
You will need to adjust the user and group according to your Apache installation and
security policy. Be aware that the directory containing the comments database must
be writable by the user or group running the WSGI daemon process: having a writable
You will need to adjust the user and group according to your Apache installation and
security policy. Be also aware that the directory containing the comments database must
be writable by the user or group running the WSGI daemon process: having a writable
database only is not enough, since SQLite will need to create a lock file in the same
directory.
`mod_fastcgi <http://www.fastcgi.com/mod_fastcgi/docs/mod_fastcgi.html>`__
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You can use this method if your hosting provider doesn't allow you to have long
running processes. If FastCGI has not yet been configured in your server,
please follow these steps:
.. note:: This information may be incorrect, if you have more knowledge on how
to deploy Python via `mod_fastcgi`, consider extending/correcting this section.
@ -223,30 +195,21 @@ please follow these steps:
</Location>
</VirtualHost>
Next, to run isso as a FastCGI script you'll need to install ``flup`` with
PIP:
.. code-block:: sh
$ pip install flup
Finally, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer):
Next, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer):
.. code-block:: python
#!/usr/bin/env python
#: uncomment if you're using a virtualenv
# import sys
# sys.path.insert(0, '<your_local_path>/lib/python2.7/site-packages')
# sys.insert(0, '<your_local_path>/lib/python2.7/site-packages')
from isso import make_app, dist, config
import os
from isso import make_app
from isso.core import Config
from flup.server.fcgi import WSGIServer
application = make_app(config.load(
os.path.join(dist.location, dist.project_name, "defaults.ini"),
"/path/to/isso.cfg"))
application = make_app(Config.load("/path/to/isso.cfg"))
WSGIServer(application).run()
`Openshift <http://openshift.com>`__

@ -2,7 +2,7 @@ Overview
========
Welcome to Isso's documentation. This documentation will help you get started
fast. If you run into any problems when using Isso, you can find the answer in
fast. If you get any problems when using Isso, you can find the answer in
troubleshooting guide or you can ask me on IRC or GitHub.
Documentation overview:

@ -22,12 +22,12 @@ libraries, but most likely not all required by Isso (or in an up-to-date
version looking at you, Debian!).
That's why most Python developers use the `Python Package Index`_ to get their
dependencies. The most important rule to follow is to never install *anything* from PyPi
dependencies. But the most important rule: never install *anything* from PyPi
as root. Not because of malicious software, but because you *will* break your
system.
``easy_install`` is one tool to mess up your system. Another package manager is
``pip``. If you ever searched for an issue with Python/pip and Stackoverflow is
suggesting you ``easy_install pip`` or ``pip install --upgrade pip`` (as root
suggesting your ``easy_install pip`` or ``pip install --upgrade pip`` (as root
of course!), you are doing it wrong. `Why you should not use Python's
easy_install carelessly on Debian`_ is worth the read.
@ -39,10 +39,10 @@ package manager.
.. code-block:: sh
# for Debian/Ubuntu
~> sudo apt-get install python-setuptools python-virtualenv python-dev
~> sudo apt-get install python-setuptools python-virtualenv
# Fedora/Red Hat
~> sudo yum install python-setuptools python-virtualenv python-devel
~> sudo yum install python-setuptools python-virtualenv
The next steps should be done as regular user, not as root (although possible
but not recommended):
@ -134,7 +134,11 @@ To upgrade Isso, activate your virtual environment again, and run
Prebuilt Packages
-----------------
* Debian (since Buster): https://packages.debian.org/search?keywords=isso
* Debian: https://packages.crapouillou.net/ built from PyPi. Includes
startup scripts and vhost configurations for Lighttpd, Apache and Nginx
[`source <https://github.com/jgraichen/debian-isso>`__].
`#729218 <https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=729218>`_ is an
ITP for Debian.
* Gentoo: http://eroen.eu/cgit/cgit.cgi/eroen-overlay/tree/www-apps/isso?h=isso
not yet available in Portage, but you can use the ebuild to build Isso.
@ -145,18 +149,7 @@ Prebuilt Packages
* Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr
repository. Built from Pypi, includes a systemctl unit script.
Build a Docker image
--------------------
You can get a Docker image by running ``docker build . -t
isso``. Assuming you have your configuration in ``/opt/isso``, you can
use the following command to spawn the Docker container:
.. code-block:: sh
~> docker run -d --rm --name isso -p 127.0.0.1:8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso
Then, you can use a reverse proxy to expose port 8080.
* Docker Image: https://registry.hub.docker.com/u/bl4n/isso/
Install from Source
-------------------
@ -228,7 +221,7 @@ don't use FastCGi or uWSGI:
If you're writing your own init script, you can utilize ``start-stop-daemon``
to run Isso in the background (Isso runs in the foreground usually). Below you
will find a very basic SysVinit script which you can use for inspiration:
find a very basic SysVinit script which you can use for inspiration:
.. code-block:: sh

@ -6,7 +6,7 @@ What's Isso?
Isso is a lightweight commenting server similar to Disqus. It allows anonymous
comments, maintains identity and is simple to administrate. It uses JavaScript
and cross-origin resource sharing for easy integration into static websites.
and cross-origin ressource sharing for easy integration into static websites.
No, I meant "Isso"
------------------

@ -2,7 +2,7 @@ Quickstart
==========
Assuming you have successfully :doc:`installed <install>` Isso, here's
a quickstart guide that covers the most common setup. Sections covered:
a quickstart quide that covers the most common setup. Sections covered:
.. contents::
:local:
@ -18,8 +18,7 @@ sane defaults.
.. code-block:: ini
[general]
; database location, check permissions, automatically created if it
does not exist
; database location, check permissions, automatically created if not exists
dbpath = /var/lib/isso/comments.db
; your website or blog (not the location of Isso!)
host = http://example.tld/
@ -61,9 +60,7 @@ For more options, see :doc:`server <configuration/server>` and :doc:`client
Migration
---------
Isso provides a tool for importing comments from Disqus_ or WordPress_.
You can also import comments from any other comment system, but this topic is more
complex and is covered in :doc:`advanced migration <extras/advanced-migration>`.
You can import comments from Disqus_ or WordPress_.
To export your comments from Disqus, log into Disqus, go to your website, click
on *Discussions* and select the *Export* tab. You'll receive an email with your
@ -77,7 +74,7 @@ Now import the XML dump:
.. code-block:: sh
~> isso -c /path/to/isso.cfg import -t [disqus|wordpress] disqus-or-wordpress.xml
~> isso -c /path/to/isso.cfg import disqus-or-wordpress.xml
[100%] 53 threads, 192 comments
.. _Disqus: https://disqus.com/
@ -141,7 +138,7 @@ a comment to see if the setup works. If not, see :doc:`troubleshooting`.
Going Further
-------------
There are several server and client configuration options not covered in this
There are several server and client configuration options uncovered in this
quickstart, check out :doc:`configuration/server` and
:doc:`configuration/client` for more information. For further website
integration, see :doc:`extras/advanced-integration`.

@ -5,8 +5,8 @@ Multiple Sites
--------------
Isso is designed to serve comments for a single website and therefore stores
comments for a relative URL. This is done to support HTTP, HTTPS and even domain transfers
without manual intervention. You can chain Isso to support multiple
comments for a relative URL to support HTTP, HTTPS and even domain transfers
without manual intervention. But you can chain Isso to support multiple
websites on different domains.
The following example uses `gunicorn <http://gunicorn.org/>`_ as WSGI server (

@ -1,12 +1,6 @@
Troubleshooting
===============
For uberspace users
-------------------
Some uberspace users experienced problems with isso and they solved their
issues by adding `DirectoryIndex disabled` as the first line in the `.htaccess`
file for the domain the isso server is running on.
pkg_ressources.DistributionNotFound
-----------------------------------
@ -29,9 +23,9 @@ encoding either in :envvar:`LANG`, :envvar:`LANGUAGE`, :envvar:`LC_ALL` or
$ env LANG=C.UTF-8 isso [-h] [--version] ...
If none of the mentioned variables are set, the interaction with Isso will
If none of the mentioned variables is set, the interaction with Isso will
likely fail (unable to print non-ascii characters to stdout/err, unable to
parse configuration file with non-ascii characters and so forth).
parse configuration file with non-ascii characters and to forth).
The web console shows 404 Not Found responses
---------------------------------------------

@ -1,17 +1,32 @@
Frequently asked question
=========================
Why not use Gravatar/Libravatar/... ?
-------------------------------------
Various people asked or complained about the generated icons next to their
comments. First, it is not an avatar, it is an identicon used to
*identify* an author of multiple comments without leaking personal
informations (unlike Gravatar).
If you are in need of Gravatar_, then use Disqus. If you run your own
Libravatar_ server, you can work on a patch for Isso which adds *optional*
support for avatars.
.. _Gravatar: https://secure.gravatar.com/
.. _Libravatar: http://libravatar.org/
Why SQLite3?
------------
Although partially answered on the index page, here is a more complete answer: If
Although partially answered on the index page, here a more complete answer: If
you manage massive amounts of comments, Isso is a really bad choice. Isso is
designed to be simple and easy to setup, it is not optimized for high-traffic
designed to be simple and easy to setup, not optimizied for high-traffic
websites (use a `dedicated Disqus`_ instance then).
Comments are not big data.
comments are not big data
For example, if you have 209 threads and 778 comments in total this only needs 620 kilobytes
of memory. This is an excellent use case for SQLite.
For example, 209 threads and 778 comments in total only need 620K (kilobyte)
memory. Excellent use case for SQLite.
.. _dedicated Disqus:

@ -1,10 +0,0 @@
Releasing steps
===============
* Run ``python3 setup.py nosetests``, ``python2 setup.py nosetests``
* Update version number in ``setup.py`` and ``CHANGES.rst``
* ``git commit -m "Preparing ${VERSION}" setup.py CHANGES.rst``
* ``git tag -as ${VERSION}``
* ``make init all``
* ``python3 setup.py sdist``
* ``twine upload --sign dist/isso-${VERSION}.tar.gz``

@ -57,11 +57,11 @@ from itsdangerous import URLSafeTimedSerializer
from werkzeug.routing import Map
from werkzeug.exceptions import HTTPException, InternalServerError
from werkzeug.middleware.shared_data import SharedDataMiddleware
from werkzeug.wsgi import SharedDataMiddleware
from werkzeug.local import Local, LocalManager
from werkzeug.serving import run_simple
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.middleware.profiler import ProfilerMiddleware
from werkzeug.contrib.fixers import ProxyFix
from werkzeug.contrib.profiler import ProfilerMiddleware
local = Local()
local_manager = LocalManager([local])
@ -82,14 +82,6 @@ logging.basicConfig(
logger = logging.getLogger("isso")
class ProxyFixCustom(ProxyFix):
def __init__(self, app):
# This is needed for werkzeug.wsgi.get_current_url called in isso/views/comments.py
# to work properly when isso is hosted under a sub-path
# cf. https://werkzeug.palletsprojects.com/en/1.0.x/middleware/proxy_fix/
super().__init__(app, x_prefix=1)
class Isso(object):
def __init__(self, conf):
@ -104,16 +96,13 @@ class Isso(object):
super(Isso, self).__init__(conf)
subscribers = []
smtp_backend = False
for backend in conf.getlist("general", "notify"):
if backend == "stdout":
subscribers.append(Stdout(None))
elif backend in ("smtp", "SMTP"):
smtp_backend = True
subscribers.append(SMTP(self))
else:
logger.warn("unknown notification backend '%s'", backend)
if smtp_backend or conf.getboolean("general", "reply-notifications"):
subscribers.append(SMTP(self))
self.signal = ext.Signal(*subscribers)
@ -210,7 +199,7 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
allowed=("Origin", "Referer", "Content-Type"),
exposed=("X-Set-Cookie", "Date")))
wrapper.extend([wsgi.SubURI, ProxyFixCustom])
wrapper.extend([wsgi.SubURI, ProxyFix])
if werkzeug.version.startswith("0.8"):
wrapper.append(wsgi.LegacyWerkzeugMiddleware)
@ -233,7 +222,7 @@ def main():
imprt.add_argument("-n", "--dry-run", dest="dryrun", action="store_true",
help="perform a trial run with no changes made")
imprt.add_argument("-t", "--type", dest="type", default=None,
choices=["disqus", "wordpress", "generic"], help="export type")
choices=["disqus", "wordpress"], help="export type")
imprt.add_argument("--empty-id", dest="empty_id", action="store_true",
help="workaround for weird Disqus XML exports, #135")

@ -13,14 +13,12 @@ if not PY2K:
buffer = memoryview
filter, map, zip = filter, map, zip
def iteritems(dikt):
return iter(dikt.items()) # noqa: E731
def iteritems(dikt): return iter(dikt.items()) # noqa: E731
from functools import reduce
else:
buffer = buffer
from itertools import ifilter, imap, izip
filter, map, zip = ifilter, imap, izip
def iteritems(dikt):
return dikt.iteritems() # noqa: E731
def iteritems(dikt): return dikt.iteritems() # noqa: E731
reduce = reduce

@ -38,7 +38,7 @@ def timedelta(string):
"""
keys = ["weeks", "days", "hours", "minutes", "seconds"]
regex = "".join(["((?P<%s>\\d+)%s ?)?" % (k, k[0]) for k in keys])
regex = "".join(["((?P<%s>\d+)%s ?)?" % (k, k[0]) for k in keys])
kwargs = {}
for k, v in re.match(regex, string).groupdict(default="0").items():
kwargs[k] = int(v)
@ -123,9 +123,8 @@ def new(options=None):
def load(default, user=None):
# return set of (section, option)
def setify(cp):
return set((section, option) for section in cp.sections()
for option in cp.options(section))
def setify(cp): return set((section, option) for section in cp.sections()
for option in cp.options(section))
parser = new()
parser.read(default)

@ -19,8 +19,7 @@ if PY2K:
else:
import _thread as thread
from flask_caching.backends.null import NullCache
from flask_caching.backends.simple import SimpleCache
from werkzeug.contrib.cache import NullCache, SimpleCache
logger = logging.getLogger("isso")
@ -124,8 +123,7 @@ class uWSGIMixin(Mixin):
timedelta = conf.getint("moderation", "purge-after")
def purge(signum):
return self.db.comments.purge(timedelta)
def purge(signum): return self.db.comments.purge(timedelta)
uwsgi.register_signal(1, "", purge)
uwsgi.add_timer(1, timedelta)

@ -15,14 +15,6 @@
color: #555;
font-weight: bold;
}
#isso-thread > .isso-feedlink {
float: right;
padding-left: 1em;
}
#isso-thread > .isso-feedlink > a {
font-size: 0.8em;
vertical-align: bottom;
}
#isso-thread .textarea {
min-height: 58px;
outline: 0;
@ -31,26 +23,31 @@
color: #757575;
}
#isso-root .isso-comment {
.isso-comment {
max-width: 68em;
padding-top: 0.95em;
margin: 0.95em auto;
}
#isso-root .preview .isso-comment {
padding-top: 0;
margin: 0;
}
#isso-root .isso-comment:not(:first-of-type),
.isso-comment:not(:first-of-type),
.isso-follow-up .isso-comment {
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.isso-comment > div.avatar {
.isso-comment > div.avatar,
.isso-postbox > .avatar {
display: block;
float: left;
width: 7%;
margin: 3px 15px 0 0;
}
.isso-comment > div.avatar > svg {
.isso-postbox > .avatar {
float: left;
margin: 5px 10px 0 5px;
width: 48px;
height: 48px;
overflow: hidden;
}
.isso-comment > div.avatar > svg,
.isso-postbox > .avatar > svg {
max-width: 48px;
max-height: 48px;
border: 1px solid rgba(0, 0, 0, 0.2);
@ -93,8 +90,7 @@
font-weight: bold;
color: #555;
}
.isso-comment > div.text-wrapper > .textarea-wrapper .textarea,
.isso-comment > div.text-wrapper > .textarea-wrapper .preview {
.isso-comment > div.text-wrapper > .textarea-wrapper .textarea {
margin-top: 0.2em;
}
.isso-comment > div.text-wrapper > div.text p {
@ -112,8 +108,7 @@
font-size: 130%;
font-weight: bold;
}
.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea,
.isso-comment > div.text-wrapper > div.textarea-wrapper .preview {
.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea {
width: 100%;
border: 1px solid #f0f0f0;
border-radius: 2px;
@ -124,12 +119,10 @@
color: gray !important;
clear: left;
}
.isso-feedlink,
.isso-comment > div.text-wrapper > .isso-comment-footer a {
font-weight: bold;
text-decoration: none;
}
.isso-feedlink:hover,
.isso-comment > div.text-wrapper > .isso-comment-footer a:hover {
color: #111111 !important;
text-shadow: #aaaaaa 0 0 1px !important;
@ -152,14 +145,13 @@
.isso-comment .isso-postbox {
margin-top: 0.8em;
}
.isso-comment.isso-no-votes > * > .isso-comment-footer span.votes {
.isso-comment.isso-no-votes span.votes {
display: none;
}
.isso-postbox {
max-width: 68em;
margin: 0 auto 2em;
clear: right;
}
.isso-postbox > .form-wrapper {
display: block;
@ -169,8 +161,7 @@
.isso-postbox > .form-wrapper > .auth-section .post-action {
display: block;
}
.isso-postbox > .form-wrapper .textarea,
.isso-postbox > .form-wrapper .preview {
.isso-postbox > .form-wrapper .textarea {
margin: 0 0 .3em;
padding: .4em .8em;
border-radius: 3px;
@ -178,16 +169,6 @@
border: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.isso-postbox > .form-wrapper input[type=checkbox] {
vertical-align: middle;
position: relative;
bottom: 1px;
margin-left: 0;
}
.isso-postbox > .form-wrapper .notification-section {
font-size: 0.90em;
padding-top: .3em;
}
#isso-thread .textarea:focus,
#isso-thread input:focus {
border-color: rgba(0, 0, 0, 0.8);
@ -210,7 +191,7 @@
.isso-postbox > .form-wrapper > .auth-section .post-action {
display: inline-block;
float: right;
margin: 0 0 0 5px;
margin: 0;
}
.isso-postbox > .form-wrapper > .auth-section .post-action > input {
padding: calc(.3em - 1px);
@ -228,32 +209,6 @@
.isso-postbox > .form-wrapper > .auth-section .post-action > input:active {
background-color: #BBB;
}
.isso-postbox > .form-wrapper .preview,
.isso-postbox > .form-wrapper input[name="edit"],
.isso-postbox.preview-mode > .form-wrapper input[name="preview"],
.isso-postbox.preview-mode > .form-wrapper .textarea {
display: none;
}
.isso-postbox.preview-mode > .form-wrapper .preview {
display: block;
}
.isso-postbox.preview-mode > .form-wrapper input[name="edit"] {
display: inline;
}
.isso-postbox > .form-wrapper .preview {
background-color: #f8f8f8;
background: repeating-linear-gradient(
-45deg,
#f8f8f8,
#f8f8f8 10px,
#fff 10px,
#fff 20px
);
}
.isso-postbox > .form-wrapper > .notification-section {
display: none;
padding-bottom: 10px;
}
@media screen and (max-width:600px) {
.isso-postbox > .form-wrapper > .auth-section .input-wrapper {
display: block;
@ -263,4 +218,9 @@
.isso-postbox > .form-wrapper > .auth-section .input-wrapper input {
width: 100%;
}
.isso-postbox > .form-wrapper > .auth-section .post-action {
display: block;
float: none;
text-align: right;
}
}

@ -98,8 +98,7 @@ class SQLite3:
# limit max. nesting level to 1
if self.version == 2:
def first(rv):
return list(map(operator.itemgetter(0), rv))
def first(rv): return list(map(operator.itemgetter(0), rv))
with sqlite3.connect(self.path) as con:
top = first(con.execute(

@ -1,18 +1,11 @@
# -*- encoding: utf-8 -*-
import logging
import time
from isso.utils import Bloomfilter
from isso.compat import buffer
logger = logging.getLogger("isso")
MAX_LIKES_AND_DISLIKES = 142
class Comments:
"""Hopefully DB-independend SQL to store, modify and retrieve all
comment-related actions. Here's a short scheme overview:
@ -30,7 +23,7 @@ class Comments:
'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', 'notification']
'likes', 'dislikes', 'voters']
def __init__(self, db):
@ -40,12 +33,7 @@ class Comments:
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,',
' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,',
' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,',
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL,',
' notification INTEGER DEFAULT 0);'])
try:
self.db.execute(['ALTER TABLE comments ADD COLUMN notification INTEGER DEFAULT 0;'])
except Exception:
pass
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);'])
def add(self, uri, c):
"""
@ -62,16 +50,16 @@ class Comments:
'INSERT INTO comments (',
' tid, parent,'
' created, modified, mode, remote_addr,',
' text, author, email, website, voters, notification)',
' text, author, email, website, voters )',
'SELECT',
' threads.id, ?,',
' ?, ?, ?, ?,',
' ?, ?, ?, ?, ?, ?',
' ?, ?, ?, ?, ?',
'FROM threads WHERE threads.uri = ?;'], (
c.get('parent'),
c.get('created') or time.time(), None, c["mode"], c['remote_addr'],
c['text'], c.get('author'), c.get('email'), c.get('website'), buffer(
Bloomfilter(iterable=[c['remote_addr']]).array), c.get('notification'),
Bloomfilter(iterable=[c['remote_addr']]).array),
uri)
)
@ -88,34 +76,6 @@ class Comments:
' mode=1',
'WHERE id=? AND mode=2'], (id, ))
def is_previously_approved_author(self, email):
"""
Search for previously activated comments with this author email.
"""
# if the user has not entered email, email is None, in which case we can't check if they have previous comments
if email is not None:
# search for any activated comments within the last 6 months by email
# this SQL should be one of the fastest ways of doing this check
# https://stackoverflow.com/questions/18114458/fastest-way-to-determine-if-record-exists
rv = self.db.execute([
'SELECT CASE WHEN EXISTS(',
' select * from comments where email=? and mode=1 and ',
' created > strftime("%s", DATETIME("now", "-6 month"))',
') THEN 1 ELSE 0 END;'], (email,)).fetchone()
return rv[0] == 1
else:
return False
def unsubscribe(self, email, id):
"""
Turn off email notifications for replies to this comment.
"""
self.db.execute([
'UPDATE comments SET',
' notification=0',
'WHERE email=? AND (id=? OR parent=?);'], (email, id, id))
def update(self, id, data):
"""
Update comment :param:`id` with values from :param:`data` and return
@ -163,7 +123,8 @@ class Comments:
for f in fields_comments])
sql_threads_fields = ', '.join(['threads.' + f
for f in fields_threads])
sql = ['SELECT ' + sql_comments_fields + ', ' + sql_threads_fields + ' '
sql = ['SELECT ' + sql_comments_fields + ', ' +
sql_threads_fields + ' '
'FROM comments INNER JOIN threads '
'ON comments.tid=threads.id '
'WHERE comments.mode = ? ']
@ -198,8 +159,7 @@ class Comments:
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', asc=1, 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`.
"""
@ -221,8 +181,7 @@ class Comments:
order_by = 'id'
sql.append('ORDER BY ')
sql.append(order_by)
if not asc:
sql.append(' DESC')
sql.append(' ASC')
if limit:
sql.append('LIMIT ?')
@ -287,18 +246,13 @@ class Comments:
if rv is None:
return None
operation_name = 'Upvote' if upvote else 'Downvote'
likes, dislikes, voters = rv
if likes + dislikes >= MAX_LIKES_AND_DISLIKES:
message = '{} denied due to a "likes + dislikes" total too high ({} >= {})'.format(operation_name, likes + dislikes, MAX_LIKES_AND_DISLIKES)
logger.debug('Comments.vote(id=%s): %s', id, message)
return {'likes': likes, 'dislikes': dislikes, 'message': message}
if likes + dislikes >= 142:
return {'likes': likes, 'dislikes': dislikes}
bf = Bloomfilter(bytearray(voters), likes + dislikes)
if remote_addr in bf:
message = '{} denied because a vote has already been registered for this remote address: {}'.format(operation_name, remote_addr)
logger.debug('Comments.vote(id=%s): %s', id, message)
return {'likes': likes, 'dislikes': dislikes, 'message': message}
return {'likes': likes, 'dislikes': dislikes}
bf.add(remote_addr)
self.db.execute([

@ -25,9 +25,6 @@ class Threads(object):
def __getitem__(self, uri):
return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone())
def get(self, id):
return Thread(*self.db.execute("SELECT * FROM threads WHERE id=?", (id, )).fetchone())
def new(self, uri, title):
self.db.execute(
"INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))

@ -0,0 +1,17 @@
<!DOCTYPE html>
<head>
<title>Isso Demo</title>
<meta charset="utf-8">
</head>
<body>
<div id="page" style="text-align:center;">
<div id="wrapper" style="width: 900px; text-align: left; margin-left: auto; margin-right: auto;">
<h2><a href="index.docker.html">Isso Demo</a></h2>
<script src="http://localhost:8081/js/embed.min.js"></script>
<section id="isso-thread" data-title="Isso Test"></section>
</div>
</div>
</body>

@ -8,7 +8,7 @@ import logging
from glob import glob
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from werkzeug.wsgi import DispatcherMiddleware
from werkzeug.wrappers import Response
from isso import dist, make_app, wsgi, config

@ -14,11 +14,6 @@ from email.utils import formatdate
from email.header import Header
from email.mime.text import MIMEText
try:
from urllib.parse import quote
except ImportError:
from urllib import quote
import logging
logger = logging.getLogger("isso")
@ -36,10 +31,31 @@ else:
from _thread import start_new_thread
class SMTPConnection(object):
class SMTP(object):
def __init__(self, isso):
def __init__(self, conf):
self.conf = conf
self.isso = isso
self.conf = isso.conf.section("smtp")
# test SMTP connectivity
try:
with self:
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.exception("unable to connect to SMTP server")
if uwsgi:
def spooler(args):
try:
self._sendmail(args[b"subject"].decode("utf-8"),
args["body"].decode("utf-8"))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
return uwsgi.SPOOL_OK
uwsgi.spooler = spooler
def __enter__(self):
klass = (smtplib.SMTP_SSL if self.conf.get(
@ -69,53 +85,15 @@ class SMTPConnection(object):
def __exit__(self, exc_type, exc_value, traceback):
self.client.quit()
class SMTP(object):
def __init__(self, isso):
self.isso = isso
self.conf = isso.conf.section("smtp")
self.public_endpoint = isso.conf.get("server", "public-endpoint")
# rstrips potential trailing '/', without having to `repr` the `local` object.
if self.public_endpoint:
self.public_endpoint = self.public_endpoint.rstrip('/')
else:
self.public_endpoint = local("host")
self.admin_notify = any((n in ("smtp", "SMTP")) for n in isso.conf.getlist("general", "notify"))
self.reply_notify = isso.conf.getboolean("general", "reply-notifications")
# test SMTP connectivity
try:
with SMTPConnection(self.conf):
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.exception("unable to connect to SMTP server")
if uwsgi:
def spooler(args):
try:
self._sendmail(args[b"subject"].decode("utf-8"),
args["body"].decode("utf-8"),
args[b"to"].decode("utf-8"))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
return uwsgi.SPOOL_OK
uwsgi.spooler = spooler
def __iter__(self):
yield "comments.new:after-save", self.notify_new
yield "comments.activate", self.notify_activated
yield "comments.new:after-save", self.notify
def format(self, thread, comment, parent_comment, recipient=None, admin=False):
def format(self, thread, comment):
rv = io.StringIO()
author = comment["author"] or "Anonymous"
if admin and comment["email"]:
if comment["email"]:
author += " <%s>" % comment["email"]
rv.write(author + " wrote:\n")
@ -123,80 +101,40 @@ class SMTP(object):
rv.write(comment["text"] + "\n")
rv.write("\n")
if admin:
if comment["website"]:
rv.write("User's URL: %s\n" % comment["website"])
rv.write("IP address: %s\n" % comment["remote_addr"])
if comment["website"]:
rv.write("User's URL: %s\n" % comment["website"])
rv.write("IP address: %s\n" % comment["remote_addr"])
rv.write("Link to comment: %s\n" %
(local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
rv.write("\n")
rv.write("---\n")
if admin:
uri = self.public_endpoint + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
uri = local("host") + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
if comment["mode"] == 2:
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
else:
uri = self.public_endpoint + "/id/%i" % parent_comment["id"]
key = self.isso.sign(('unsubscribe', recipient))
rv.write("---\n")
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key))
if comment["mode"] == 2:
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
rv.seek(0)
return rv.read()
def notify_new(self, thread, comment):
if self.admin_notify:
body = self.format(thread, comment, None, admin=True)
subject = "New comment posted"
if thread['title']:
subject = "%s on %s" % (subject, thread["title"])
self.sendmail(subject, body, thread, comment)
if comment["mode"] == 1:
self.notify_users(thread, comment)
def notify_activated(self, thread, comment):
self.notify_users(thread, comment)
def notify_users(self, thread, comment):
if self.reply_notify and "parent" in comment and comment["parent"] is not None:
# Notify interested authors that a new comment is posted
notified = []
parent_comment = self.isso.db.comments.get(comment["parent"])
comments_to_notify = [parent_comment] if parent_comment is not None else []
comments_to_notify += self.isso.db.comments.fetch(thread["uri"], mode=1, parent=comment["parent"])
for comment_to_notify in comments_to_notify:
email = comment_to_notify["email"]
if "email" in comment_to_notify and comment_to_notify["notification"] and email not in notified \
and comment_to_notify["id"] != comment["id"] and email != comment["email"]:
body = self.format(thread, comment, parent_comment, email, admin=False)
subject = "Re: New comment posted on %s" % thread["title"]
self.sendmail(subject, body, thread, comment, to=email)
notified.append(email)
def sendmail(self, subject, body, thread, comment, to=None):
to = to or self.conf.get("to")
if not subject:
# Fallback, just in case as an empty subject does not work
subject = 'isso notification'
def notify(self, thread, comment):
body = self.format(thread, comment)
if uwsgi:
uwsgi.spool({b"subject": subject.encode("utf-8"),
b"body": body.encode("utf-8"),
b"to": to.encode("utf-8")})
uwsgi.spool({b"subject": thread["title"].encode("utf-8"),
b"body": body.encode("utf-8")})
else:
start_new_thread(self._retry, (subject, body, to))
start_new_thread(self._retry, (thread["title"], body))
def _sendmail(self, subject, body, to_addr):
def _sendmail(self, subject, body):
from_addr = self.conf.get("from")
to_addr = self.conf.get("to")
msg = MIMEText(body, 'plain', 'utf-8')
msg['From'] = from_addr
@ -204,13 +142,13 @@ class SMTP(object):
msg['Date'] = formatdate(localtime=True)
msg['Subject'] = Header(subject, 'utf-8')
with SMTPConnection(self.conf) as con:
with self as con:
con.sendmail(from_addr, to_addr, msg.as_string())
def _retry(self, subject, body, to):
def _retry(self, subject, body):
for x in range(5):
try:
self._sendmail(subject, body, to)
self._sendmail(subject, body)
except smtplib.SMTPConnectError:
time.sleep(60)
else:
@ -243,5 +181,5 @@ class Stdout(object):
def _delete_comment(self, id):
logger.info('comment %i deleted', id)
def _activate_comment(self, thread, comment):
logger.info("comment %(id)s activated" % thread)
def _activate_comment(self, id):
logger.info("comment %s activated" % id)

@ -1,100 +0,0 @@
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, isso_host_script) {
ajax({method: "POST",
url: isso_host_script + "/id/" + com_id + "/" + action + "/" + hash,
success: function(){
fade(document.getElementById("isso-" + com_id));
}});
}
function edit(com_id, hash, author, email, website, comment, isso_host_script) {
ajax({method: "POST",
url: isso_host_script + "/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, isso_host_script) {
moderate(com_id, hash, "activate", isso_host_script);
}
function delete_com(com_id, hash, isso_host_script) {
moderate(com_id, hash, "delete", isso_host_script);
}
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, isso_host_script) {
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, isso_host_script);
stop_edit(com_id);
}

@ -3,7 +3,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
"use strict";
var salt = "Eech7co8Ohloopo9Ol6baimi",
location = function() { return window.location.pathname };
location = window.location.pathname;
var script, endpoint,
js = document.getElementsByTagName("script");
@ -91,7 +91,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
var create = function(tid, data) {
var deferred = Q.defer();
curl("POST", endpoint + "/new?" + qs({uri: tid || location()}), JSON.stringify(data),
curl("POST", endpoint + "/new?" + qs({uri: tid || location}), JSON.stringify(data),
function (rv) {
if (rv.status === 201 || rv.status === 202) {
deferred.resolve(JSON.parse(rv.body));
@ -142,7 +142,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
if (typeof(nested_limit) === 'undefined') { nested_limit = "inf"; }
if (typeof(parent) === 'undefined') { parent = null; }
var query_dict = {uri: tid || location(), after: lastcreated, parent: parent};
var query_dict = {uri: tid || location, after: lastcreated, parent: parent};
if(limit !== "inf") {
query_dict['limit'] = limit;
@ -191,24 +191,6 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
return deferred.promise;
};
var feed = function(tid) {
return endpoint + "/feed?" + qs({uri: tid || location()});
};
var preview = function(text) {
var deferred = Q.defer();
curl("POST", endpoint + "/preview", JSON.stringify({text: text}),
function(rv) {
if (rv.status === 200) {
deferred.resolve(JSON.parse(rv.body).text);
} else {
deferred.reject(rv.body);
}
});
return deferred.promise;
};
return {
endpoint: endpoint,
salt: salt,
@ -220,8 +202,6 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
fetch: fetch,
count: count,
like: like,
dislike: dislike,
feed: feed,
preview: preview
dislike: dislike
};
});

@ -7,18 +7,15 @@ define(function() {
"reply-to-self": false,
"require-email": false,
"require-author": false,
"reply-notifications": false,
"max-comments-top": "inf",
"max-comments-nested": 5,
"reveal-on-click": 5,
"gravatar": false,
"avatar": true,
"avatar-bg": "#f0f0f0",
"avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6",
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
"vote": true,
"vote-levels": null,
"feed": false
"vote-levels": null
};
var js = document.getElementsByTagName("script");

@ -4,7 +4,7 @@ define(["app/api", "app/dom", "app/i18n"], function(api, $, i18n) {
var objs = {};
$.each("a", function(el) {
if (! el.href.match || ! el.href.match(/#isso-thread$/)) {
if (! el.href.match(/#isso-thread$/)) {
return;
}

@ -90,8 +90,6 @@ define(function() {
this.focus = function() { node.focus() };
this.scrollIntoView = function(args) { node.scrollIntoView(args) };
this.checked = function() { return node.checked; };
this.setAttribute = function(key, value) { node.setAttribute(key, value) };
this.getAttribute = function(key) { return node.getAttribute(key) };

@ -1,9 +1,9 @@
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/oc", "app/i18n/pl", "app/i18n/pt_BR", "app/i18n/sk", "app/i18n/sv", "app/i18n/nl", "app/i18n/el_GR",
"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, oc, pl, pt_BR, sk, sv, nl, el, es, vi, zh, zh_CN, 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";
@ -22,7 +22,6 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
case "hr":
case "hu":
case "it":
case "pt_BR":
case "sv":
case "nl":
case "vi":
@ -46,30 +45,6 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
return typeof msgs[2] !== "undefined" ? msgs[2] : msgs[1];
}
};
case "oc":
return function(msgs, n) {
return msgs[n > 1 ? 1 : 0];
};
case "pl":
return function(msgs, n) {
if (n === 1) {
return msgs[0];
} else if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) {
return msgs[1];
} else {
return typeof msgs[2] !== "undefined" ? msgs[2] : msgs[1];
}
};
case "sk":
return function(msgs, n) {
if (n === 1) {
return msgs[0];
} else if (n === 2 || n === 3 || n === 4) {
return msgs[1];
} else {
return typeof msgs[2] !== "undefined" ? msgs[2] : msgs[1];
}
};
default:
return null;
}
@ -98,12 +73,7 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
it: it,
hr: hr,
hu: hu,
oc: oc,
pl: pl,
pt: pt_BR,
pt_BR: pt_BR,
ru: ru,
sk: sk,
sv: sv,
nl: nl,
vi: vi,
@ -115,10 +85,7 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
var plural = pluralforms(lang);
var translate = function(msgid) {
return config[msgid + '-text-' + lang] ||
catalogue[lang][msgid] ||
en[msgid] ||
"???";
return catalogue[lang][msgid] || en[msgid] || "???";
};
var pluralize = function(msgid, n) {

@ -3,8 +3,6 @@ define({
"postbox-author": "Име/псевдоним (незадължително)",
"postbox-email": "Ел. поща (незадължително)",
"postbox-website": "Уебсайт (незадължително)",
"postbox-preview": "преглед",
"postbox-edit": "Редактиране",
"postbox-submit": "Публикуване",
"num-comments": "1 коментар\n{{ n }} коментара",
"no-comments": "Все още няма коментари",

@ -3,8 +3,6 @@ define({
"postbox-author": "Jméno (nepovinné)",
"postbox-email": "E-mail (nepovinný)",
"postbox-website": "Web (nepovinný)",
"postbox-preview": "Náhled",
"postbox-edit": "Upravit",
"postbox-submit": "Publikovat",
"num-comments": "Jeden komentář\n{{ n }} Komentářů",
"no-comments": "Zatím bez komentářů",

@ -3,8 +3,6 @@ define({
"postbox-author": "Name (optional)",
"postbox-email": "E-mail (optional)",
"postbox-website": "Website (optional)",
"postbox-preview": "Eksempel",
"postbox-edit": "Rediger",
"postbox-submit": "Submit",
"num-comments": "One Comment\n{{ n }} Comments",

@ -1,12 +1,9 @@
define({
"postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)",
"postbox-author": "Name (optional)",
"postbox-email": "E-Mail (optional)",
"postbox-email": "Email (optional)",
"postbox-website": "Website (optional)",
"postbox-preview": "Vorschau",
"postbox-edit": "Bearbeiten",
"postbox-submit": "Abschicken",
"postbox-notification": "wenn auf meinen Kommentar geantwortet wird, möchte ich eine E-Mail bekommen",
"num-comments": "1 Kommentar\n{{ n }} Kommentare",
"no-comments": "Bisher keine Kommentare",
"comment-reply": "Antworten",

@ -3,8 +3,6 @@ define({
"postbox-author": "Όνομα (προαιρετικό)",
"postbox-email": "E-mail (προαιρετικό)",
"postbox-website": "Ιστοσελίδα (προαιρετικό)",
"postbox-preview": "Πρεμιέρα",
"postbox-edit": "Επεξεργασία",
"postbox-submit": "Υποβολή",
"num-comments": "Ένα σχόλιο\n{{ n }} σχόλια",
"no-comments": "Δεν υπάρχουν σχόλια",

@ -3,14 +3,10 @@ define({
"postbox-author": "Name (optional)",
"postbox-email": "E-mail (optional)",
"postbox-website": "Website (optional)",
"postbox-preview": "Preview",
"postbox-edit": "Edit",
"postbox-submit": "Submit",
"postbox-notification": "Subscribe to email notification of replies",
"num-comments": "One Comment\n{{ n }} Comments",
"no-comments": "No Comments Yet",
"atom-feed": "Atom feed",
"comment-reply": "Reply",
"comment-edit": "Edit",

@ -3,8 +3,6 @@ define({
"postbox-author": "Nomo (malnepra)",
"postbox-email": "Retadreso (malnepra)",
"postbox-website": "Retejo (malnepra)",
"postbox-preview": "Antaŭrigardo",
"postbox-edit": "Redaktu",
"postbox-submit": "Sendu",
"num-comments": "{{ n }} komento\n{{ n }} komentoj",
"no-comments": "Neniu komento ankoraŭ",

@ -3,8 +3,6 @@ define({
"postbox-author": "Nombre (opcional)",
"postbox-email": "E-mail (opcional)",
"postbox-website": "Sitio web (opcional)",
"postbox-preview": "Vista preliminar",
"postbox-edit": "Editar",
"postbox-submit": "Enviar",
"num-comments": "Un Comentario\n{{ n }} Comentarios",
"no-comments": "Sin Comentarios Todavía",

@ -1,14 +1,12 @@
define({
"postbox-text": "نظر خود را اینجا بنویسید (حداقل سه نویسه)",
"postbox-text": "نظر خود را اینجا بنویسید (حداقل سه کرکتر)",
"postbox-author": "اسم (اختیاری)",
"postbox-email": "ایمیل (اختیاری)",
"postbox-website": "سایت (اختیاری)",
"postbox-preview": "پیش‌نمایش",
"postbox-edit": "ویرایش",
"postbox-submit": "ارسال",
"num-comments": "یک نظر\n{{ n }} نظر",
"no-comments": "هنوز نظری نوشته نشده است",
"no-comments": "هنوز نظری نوشته نشده",
"comment-reply": "پاسخ",
"comment-edit": "ویرایش",
@ -17,8 +15,8 @@ define({
"comment-confirm": "تایید",
"comment-close": "بستن",
"comment-cancel": "انصراف",
"comment-deleted": "نظر حذف شد.",
"comment-queued": "نظر در صف بررسی مدیر قرار دارد.",
"comment-deleted": "کامنت حذف شد.",
"comment-queued": "کامنت در صف بررسی مدیر قرار دارد.",
"comment-anonymous": "ناشناس",
"comment-hidden": "{{ n }} مخفی",

@ -3,8 +3,6 @@ define({
"postbox-author": "Nimi (valinnainen)",
"postbox-email": "Sähköposti (valinnainen)",
"postbox-website": "Web-sivu (valinnainen)",
"postbox-preview": "Esikatselu",
"postbox-edit": "Muokkaa",
"postbox-submit": "Lähetä",
"num-comments": "Yksi kommentti\n{{ n }} kommenttia",

@ -3,13 +3,9 @@ define({
"postbox-author": "Nom (optionnel)",
"postbox-email": "Courriel (optionnel)",
"postbox-website": "Site web (optionnel)",
"postbox-preview": "Aperçu",
"postbox-edit": "Éditer",
"postbox-submit": "Soumettre",
"postbox-notification": "Sabonner aux notifications de réponses",
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
"no-comments": "Aucun commentaire pour linstant",
"atom-feed": "Flux Atom",
"no-comments": "Aucun commentaire pour l'instant",
"comment-reply": "Répondre",
"comment-edit": "Éditer",
"comment-save": "Enregistrer",
@ -21,7 +17,7 @@ define({
"comment-queued": "Commentaire en attente de modération.",
"comment-anonymous": "Anonyme",
"comment-hidden": "1 caché\n{{ n }} cachés",
"date-now": "À linstant",
"date-now": "À l'instant",
"date-minute": "Il y a une minute\nIl y a {{ n }} minutes",
"date-hour": "Il y a une heure\nIl y a {{ n }} heures ",
"date-day": "Hier\nIl y a {{ n }} jours",

@ -3,8 +3,6 @@ define({
"postbox-author": "Ime (neobavezno)",
"postbox-email": "E-mail (neobavezno)",
"postbox-website": "Web stranica (neobavezno)",
"postbox-preview": "Pregled",
"postbox-edit": "Uredi",
"postbox-submit": "Pošalji",
"num-comments": "Jedan komentar\n{{ n }} komentara",
"no-comments": "Još nema komentara",

@ -3,8 +3,6 @@ define({
"postbox-author": "Név (nem kötelező)",
"postbox-email": "Email (nem kötelező)",
"postbox-website": "Website (nem kötelező)",
"postbox-preview": "Előnézet",
"postbox-edit": "Szerekesztés",
"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",

@ -3,8 +3,6 @@ define({
"postbox-author": "Nome (opzionale)",
"postbox-email": "E-mail (opzionale)",
"postbox-website": "Sito web (opzionale)",
"postbox-preview": "Anteprima",
"postbox-edit": "Modifica",
"postbox-submit": "Invia",
"num-comments": "Un Commento\n{{ n }} Commenti",
"no-comments": "Ancora Nessun Commento",

@ -3,8 +3,6 @@ define({
"postbox-author": "Naam (optioneel)",
"postbox-email": "E-mail (optioneel)",
"postbox-website": "Website (optioneel)",
"postbox-preview": "Voorbeeld",
"postbox-edit": "Bewerken",
"postbox-submit": "Versturen",
"num-comments": "Één reactie\n{{ n }} reacties",
"no-comments": "Nog geen reacties",

@ -1,34 +0,0 @@
define({
"postbox-text": "Escriure lo comentari aquí (almens 3 caractèrs)",
"postbox-author": "Nom (opcional)",
"postbox-email": "Corrièl (opcional)",
"postbox-website": "Site web (opcional)",
"postbox-preview": "Apercebut",
"postbox-edit": "Modificar",
"postbox-submit": "Enviar",
"postbox-notification": "S'abonar per corrièl a las notificacions de responsas",
"num-comments": "Un comentari\n{{ n }} comentaris",
"no-comments": "Cap de comentari pel moment",
"atom-feed": "Flux Atom",
"comment-reply": "Respondre",
"comment-edit": "Modificar",
"comment-save": "Salvar",
"comment-delete": "Suprimir",
"comment-confirm": "Confirmar",
"comment-close": "Tampar",
"comment-cancel": "Anullar",
"comment-deleted": "Comentari suprimit.",
"comment-queued": "Comentari en espèra de moderacion.",
"comment-anonymous": "Anonim",
"comment-hidden": "1 rescondut\n{{ n }} resconduts",
"date-now": "ara meteis",
"date-minute": "fa una minuta \nfa {{ n }} minutas",
"date-hour": "fa una ora\nfa {{ n }} oras",
"date-day": "Ièr\nfa {{ n }} jorns",
"date-week": "la setmana passada\nfa {{ n }} setmanas",
"date-month": "lo mes passat\nfa {{ n }} meses",
"date-year": "l'an passat\nfa {{ n }} ans"
});

@ -3,15 +3,9 @@ define({
"postbox-author": "Imię/nick (opcjonalnie)",
"postbox-email": "E-mail (opcjonalnie)",
"postbox-website": "Strona (opcjonalnie)",
"postbox-preview": "Podgląd",
"postbox-edit": "Edytuj",
"postbox-submit": "Wyślij",
"postbox-notification": "Otrzymuj powiadomienia o odpowiedziach na e-mail",
"num-comments": "Jeden komentarz\n{{ n }} komentarze\n{{ n }} komentarzy",
"no-comments": "Nie ma jeszcze komentarzy",
"atom-feed": "Kanał Atom",
"num-comments": "Jeden komentarz\n{{ n }} komentarzy",
"no-comments": "Jeszcze nie ma komentarzy",
"comment-reply": "Odpowiedz",
"comment-edit": "Edytuj",
"comment-save": "Zapisz",
@ -20,15 +14,14 @@ define({
"comment-close": "Zamknij",
"comment-cancel": "Anuluj",
"comment-deleted": "Komentarz usunięty.",
"comment-queued": "Komentarz w kolejce do moderacji.",
"comment-queued": "Komentarz w kolejce do moderacji",
"comment-anonymous": "Anonim",
"comment-hidden": "{{ n }} ukryty\n{{ n }} ukryte\n{{ n }} ukrytych",
"comment-hidden": "{{ n }} ukrytych",
"date-now": "teraz",
"date-minute": "minutę temu\n{{ n }} minuty temu\n{{ n }} minut temu",
"date-hour": "godzinę temu\n{{ n }} godziny temu\n{{ n }} godzin temu",
"date-minute": "minutę temu\n{{ n }} minut temu",
"date-hour": "godzinę temu\n{{ n }} godzin temu",
"date-day": "wczoraj\n{{ n }} dni temu",
"date-week": "w ubiegłym tygodniu\n{{ n }} tygodnie temu\n{{ n }} tygodni temu",
"date-month": "w ubiegłym miesiącu\n{{ n }} miesiące temu\n{{ n }} miesięcy temu",
"date-year": "w ubiegłym roku\n{{ n }} lata temu\n{{ n }} lat temu"
"date-week": "w ubiegłym tygodniu\n{{ n }} tygodni temu",
"date-month": "w ubiegłym miesiącu\n{{ n }} miesięcy temu",
"date-year": "w ubiegłym roku\n{{ n }} lat temu"
});

@ -1,34 +0,0 @@
define({
"postbox-text": "Digite seu comentário aqui (pelo menos 3 letras)",
"postbox-author": "Nome (opcional)",
"postbox-email": "E-mail (opcional)",
"postbox-website": "Website (opcional)",
"postbox-preview": "Prévia",
"postbox-edit": "Editar",
"postbox-submit": "Enviar",
"postbox-notification": "Receber emails de notificação de respostas",
"num-comments": "Um Comentário\n{{ n }} Comentários",
"no-comments": "Nenhum comentário ainda",
"atom-feed": "Feed Atom",
"comment-reply": "Responder",
"comment-edit": "Editar",
"comment-save": "Salvar",
"comment-delete": "Excluir",
"comment-confirm": "Confirmar",
"comment-close": "Fechar",
"comment-cancel": "Cancelar",
"comment-deleted": "Comentário apagado.",
"comment-queued": "Comentário na fila de moderação.",
"comment-anonymous": "Anônimo",
"comment-hidden": "{{ n }} Oculto(s)",
"date-now": "agora mesmo",
"date-minute": "um minuto atrás\n{{ n }} minutos atrás",
"date-hour": "uma hora atrás\n{{ n }} horas atrás",
"date-day": "ontem\n{{ n }} dias",
"date-week": "semana passada\n{{ n }} semanas atrás",
"date-month": "mês passado\n{{ n }} meses atrás",
"date-year": "ano passado\n{{ n }} anos atrás"
});

@ -3,10 +3,7 @@ define({
"postbox-author": "Имя (необязательно)",
"postbox-email": "Email (необязательно)",
"postbox-website": "Сайт (необязательно)",
"postbox-preview": "Предпросмотр",
"postbox-edit": "Правка",
"postbox-submit": "Отправить",
"postbox-notification": "Подписаться на уведомление об ответах",
"num-comments": "{{ n }} комментарий\n{{ n }} комментария\n{{ n }} комментариев",
"no-comments": "Пока нет комментариев",
"comment-reply": "Ответить",

@ -1,29 +0,0 @@
define({
"postbox-text": "Sem napíšte svoj komentár (minimálne 3 znaky)",
"postbox-author": "Meno (nepovinné)",
"postbox-email": "E-mail (nepovinný)",
"postbox-website": "Web (nepovinný)",
"postbox-preview": "Náhľad",
"postbox-edit": "Upraviť",
"postbox-submit": "Publikovať",
"num-comments": "Jeden komentár\n{{ n }} komentáre\n{{ n }} komentárov",
"no-comments": "Zatiaľ bez komentárov",
"comment-reply": "Odpovedať",
"comment-edit": "Upraviť",
"comment-save": "Uložiť",
"comment-delete": "Zmazať",
"comment-confirm": "Potvrdit",
"comment-close": "Zavrieť",
"comment-cancel": "Zrušiť",
"comment-deleted": "Komentár bol vymazaný",
"comment-queued": "Komentár zaradený na schválenie",
"comment-anonymous": "Anonym",
"comment-hidden": "{{ n }} skrytý\n{{ n }} skryté\n{{ n }} skrytých",
"date-now": "práve teraz",
"date-minute": "pred minútou\npred {{ n }} minútami",
"date-hour": "pred hodinou\npred {{ n }} hodinami",
"date-day": "včera\npred {{ n }} dňami",
"date-week": "minulý týždeň\npred {{ n }} týždňami",
"date-month": "minulý mesiac\npred {{ n }} mesiacmi",
"date-year": "minulý rok\npred {{ n }} rokmi"
});

@ -3,8 +3,6 @@ define({
"postbox-author": "Namn (frivilligt)",
"postbox-email": "E-mail (frivilligt)",
"postbox-website": "Hemsida (frivilligt)",
"postbox-preview": "Förhandsvisning",
"postbox-edit": "Redigera",
"postbox-submit": "Skicka",
"num-comments": "En kommentar\n{{ n }} kommentarer",
"no-comments": "Inga kommentarer än",

@ -3,8 +3,6 @@ define({
"postbox-author": "Tên (tùy chọn)",
"postbox-email": "E-mail (tùy chọn)",
"postbox-website": "Website (tùy chọn)",
"postbox-preview": "Xem trước",
"postbox-edit": "Sửa",
"postbox-submit": "Gửi",
"num-comments": "Một bình luận\n{{ n }} bình luận",

@ -1,14 +1,11 @@
define({
"postbox-text": "在此输入评论 (最少 3 个字符)",
"postbox-text": "在此输入评论 (最少3个字符)",
"postbox-author": "名字 (可选)",
"postbox-email": "电子邮箱 (可选)",
"postbox-email": "E-mail (可选)",
"postbox-website": "网站 (可选)",
"postbox-preview": "预览",
"postbox-edit": "编辑",
"postbox-submit": "提交",
"postbox-notification": "有新回复时发送邮件通知",
"num-comments": "1 条评论\n{{ n }} 条评论",
"num-comments": "1条评论\n{{ n }}条评论",
"no-comments": "还没有评论",
"comment-reply": "回复",
@ -24,10 +21,10 @@ define({
"comment-hidden": "{{ n }} 条评论已隐藏",
"date-now": "刚刚",
"date-minute": "1 分钟前\n{{ n }} 分钟前",
"date-hour": "1 小时前\n{{ n }} 小时前",
"date-day": "昨天\n{{ n }} 天前",
"date-week": "上周\n{{ n }} 周前",
"date-month": "上个月\n{{ n }} 个月前",
"date-year": "去年\n{{ n }} 年前"
"date-minute": "1分钟前\n{{ n }}分钟前",
"date-hour": "1小时前\n{{ n }}小时前",
"date-day": "昨天\n{{ n }}天前",
"date-week": "上周\n{{ n }}周前",
"date-month": "上个月\n{{ n }}个月前",
"date-year": "去年\n{{ n }}年前"
});

@ -1,33 +1,30 @@
define({
"postbox-text": "在此輸入留言 (至少 3 個字元)",
"postbox-author": "名稱 (非必填)",
"postbox-email": "電子信箱 (非必填)",
"postbox-website": "個人網站 (非必填)",
"postbox-preview": "預覽",
"postbox-edit": "編輯",
"postbox-submit": "送出",
"postbox-notification": "訂閱回复的電子郵件通知",
"num-comments": "1 則留言\n{{ n }} 則留言",
"no-comments": "尚無留言",
"comment-reply": "回覆",
"comment-edit": "編輯",
"comment-save": "儲存",
"comment-delete": "刪除",
"comment-confirm": "確認",
"comment-close": "關閉",
"comment-cancel": "取消",
"comment-deleted": "留言已刪",
"comment-queued": "留言待審",
"comment-anonymous": "匿名",
"comment-hidden": "{{ n }} 則隱藏留言",
"date-now": "剛剛",
"date-minute": "1 分鐘前\n{{ n }} 分鐘前",
"date-hour": "1 小時前\n{{ n }} 小時前",
"date-day": "昨天\n{{ n }} 天前",
"date-week": "上週\n{{ n }} 週前",
"date-month": "上個月\n{{ n }} 個月前",
"date-year": "去年\n{{ n }} 年前"
});
define({
"postbox-text": "在此輸入留言至少3個字元",
"postbox-author": "名稱 (非必填)",
"postbox-email": "電子信箱 (非必填)",
"postbox-website": "個人網站 (非必填)",
"postbox-submit": "送出",
"num-comments": "1則留言\n{{ n }}則留言",
"no-comments": "尚無留言",
"comment-reply": "回覆",
"comment-edit": "編輯",
"comment-save": "儲存",
"comment-delete": "刪除",
"comment-confirm": "確認",
"comment-close": "關閉",
"comment-cancel": "取消",
"comment-deleted": "留言已刪",
"comment-queued": "留言待審",
"comment-anonymous": "匿名",
"comment-hidden": "{{ n }}則隱藏留言",
"date-now": "剛剛",
"date-minute": "1分鐘前\n{{ n }}分鐘前",
"date-hour": "1小時前\n{{ n }}小時前",
"date-day": "昨天\n{{ n }}天前",
"date-week": "上週\n{{ n }}週前",
"date-month": "上個月\n{{ n }}個月前",
"date-year": "去年\n{{ n }}年前"
});

@ -11,8 +11,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
el = $.htmlify(jade.render("postbox", {
"author": JSON.parse(localStorage.getItem("author")),
"email": JSON.parse(localStorage.getItem("email")),
"website": JSON.parse(localStorage.getItem("website")),
"preview": ''
"website": JSON.parse(localStorage.getItem("website"))
}));
// callback on success (e.g. to toggle the reply button)
@ -40,17 +39,6 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
return true;
};
// only display notification checkbox if email is filled in
var email_edit = function() {
if (config["reply-notifications"] && $("[name='email']", el).value.length > 0) {
$(".notification-section", el).show();
} else {
$(".notification-section", el).hide();
}
};
$("[name='email']", el).on("input", email_edit);
email_edit();
// email is not optional if this config parameter is set
if (config["require-email"]) {
$("[name='email']", el).setAttribute("placeholder",
@ -63,27 +51,9 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("[name='author']", el).placeholder.replace(/ \(.*\)/, "");
}
// preview function
$("[name='preview']", el).on("click", function() {
api.preview(utils.text($(".textarea", el).innerHTML)).then(
function(html) {
$(".preview .text", el).innerHTML = html;
el.classList.add('preview-mode');
});
});
// edit function
var edit = function() {
$(".preview .text", el).innerHTML = '';
el.classList.remove('preview-mode');
};
$("[name='edit']", el).on("click", edit);
$(".preview", el).on("click", edit);
// submit form, initialize optional fields with `null` and reset form.
// If replied to a comment, remove form completely.
$("[type=submit]", el).on("click", function() {
edit();
if (! el.validate()) {
return;
}
@ -100,8 +70,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
author: author, email: email, website: website,
text: utils.text($(".textarea", el).innerHTML),
parent: parent || null,
title: $("#isso-thread").getAttribute("data-title") || null,
notification: $("[name=notification]", el).checked() ? 1 : 0,
title: $("#isso-thread").getAttribute("data-title") || null
}).then(function(comment) {
$(".textarea", el).innerHTML = "";
$(".textarea", el).blur();
@ -259,7 +228,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("a.edit", footer).toggle("click",
function(toggler) {
var edit = $("a.edit", footer);
var avatar = config["avatar"] || config["gravatar"] ? $(".avatar", el, false)[0] : null;
var avatar = config["avatar"] ? $(".avatar", el, false)[0] : null;
edit.textContent = i18n.translate("comment-save");
edit.insertAfter($.new("a.cancel", i18n.translate("comment-cancel"))).on("click", function() {
@ -287,7 +256,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
},
function(toggler) {
var textarea = $(".textarea", text);
var avatar = config["avatar"] || config["gravatar"] ? $(".avatar", el, false)[0] : null;
var avatar = config["avatar"] ? $(".avatar", el, false)[0] : null;
if (! toggler.canceled && textarea !== null) {
if (utils.text(textarea.innerHTML).length < 3) {

@ -7,9 +7,6 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
var load = function(name, js) {
templates[name] = (function(jade) {
var fn;
if (js.compiled) {
return js(jade);
}
eval("fn = " + js);
return fn;
})(runtime);

@ -1,7 +1,4 @@
div(class='isso-comment' id='isso-#{comment.id}')
if conf.gravatar
div(class='avatar')
img(src='#{comment.gravatar_image}')
if conf.avatar
div(class='avatar')
svg(data-hash='#{comment.hash}')

@ -3,10 +3,6 @@ div(class='isso-postbox')
div(class='textarea-wrapper')
div(class='textarea placeholder' contenteditable='true')
= i18n('postbox-text')
div(class='preview')
div(class='isso-comment')
div(class='text-wrapper')
div(class='text')
section(class='auth-section')
p(class='input-wrapper')
input(type='text' name='author' placeholder=i18n('postbox-author')
@ -19,13 +15,3 @@ div(class='isso-postbox')
value=website != null ? '#{website}' : '')
p(class='post-action')
input(type='submit' value=i18n('postbox-submit'))
p(class='post-action')
input(type='button' name='preview'
value=i18n('postbox-preview'))
p(class='post-action')
input(type='button' name='edit'
value=i18n('postbox-edit'))
section(class='notification-section')
label
input(type='checkbox' name='notification')
= i18n('postbox-notification')

@ -12,16 +12,10 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
jade.set("pluralize", i18n.pluralize);
jade.set("svg", svg);
var isso_thread;
var heading;
function init() {
isso_thread = $('#isso-thread');
heading = $.new("h4");
domready(function() {
if (config["css"] && $("style#isso-style") === null) {
if (config["css"]) {
var style = $.new("style");
style.id = "isso-style";
style.type = "text/css";
style.textContent = css.inline;
$("head").append(style);
@ -29,35 +23,20 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
count();
if (isso_thread === null) {
if ($("#isso-thread") === null) {
return console.log("abort, #isso-thread is missing");
}
if (config["feed"]) {
var feedLink = $.new('a', i18n.translate('atom-feed'));
var feedLinkWrapper = $.new('span.isso-feedlink');
feedLink.href = api.feed(isso_thread.getAttribute("data-isso-id"));
feedLinkWrapper.appendChild(feedLink);
isso_thread.append(feedLinkWrapper);
}
isso_thread.append(heading);
isso_thread.append(new isso.Postbox(null));
isso_thread.append('<div id="isso-root"></div>');
}
function fetchComments() {
if ($('#isso-root').length == 0) {
return;
}
$("#isso-thread").append($.new('h4'));
$("#isso-thread").append(new isso.Postbox(null));
$("#isso-thread").append('<div id="isso-root"></div>');
$('#isso-root').textContent = '';
api.fetch(isso_thread.getAttribute("data-isso-id") || location.pathname,
api.fetch($("#isso-thread").getAttribute("data-isso-id"),
config["max-comments-top"],
config["max-comments-nested"]).then(
function (rv) {
function(rv) {
if (rv.total_replies === 0) {
heading.textContent = i18n.translate("no-comments");
$("#isso-thread > h4").textContent = i18n.translate("no-comments");
return;
}
@ -65,19 +44,18 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
var count = rv.total_replies;
rv.replies.forEach(function(comment) {
isso.insert(comment, false);
if (comment.created > lastcreated) {
if(comment.created > lastcreated) {
lastcreated = comment.created;
}
count = count + comment.total_replies;
});
heading.textContent = i18n.pluralize("num-comments", count);
$("#isso-thread > h4").textContent = i18n.pluralize("num-comments", count);
if (rv.hidden_replies > 0) {
if(rv.hidden_replies > 0) {
isso.insert_loader(rv, lastcreated);
}
if (window.location.hash.length > 0 &&
window.location.hash.match("^#isso-[0-9]+$")) {
if (window.location.hash.length > 0) {
$(window.location.hash).scrollIntoView();
}
},
@ -85,16 +63,5 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
console.log(err);
}
);
}
domready(function() {
init();
fetchComments();
});
window.Isso = {
init: init,
fetchComments: fetchComments
};
});

@ -49,12 +49,8 @@ define(function() {
write: function(plugin, name, write) {
if (builds.hasOwnProperty(name)) {
write("define('" + plugin + "!" + name +"', function () {" +
" var wfn = function (jade) {" +
" var fn = " + builds[name] + ";" +
" return fn;" +
" };" +
"wfn.compiled = true;" +
"return wfn;" +
" var fn = " + builds[name] + ";" +
" return fn;" +
"});\n");
}
}

@ -2,14 +2,13 @@
from __future__ import division, print_function, unicode_literals
import functools
import io
import json
import logging
import sys
import os
import io
import re
import sys
import logging
import textwrap
import functools
from time import mktime, strptime, time
from collections import defaultdict
@ -56,7 +55,7 @@ class Progress(object):
if time() - self.last > 0.2:
sys.stdout.write("\r{0}".format(" " * cols))
sys.stdout.write("\r[{0:.0%}] {1}".format(i / self.end, message))
sys.stdout.write("\r[{0:.0%}] {1}".format(i/self.end, message))
sys.stdout.flush()
self.last = time()
@ -102,17 +101,15 @@ class Disqus(object):
res = defaultdict(list)
for post in tree.findall(Disqus.ns + 'post'):
email = post.find('{0}author/{0}email'.format(Disqus.ns))
ip = post.find(Disqus.ns + 'ipAddress')
item = {
'dsq:id': post.attrib.get(Disqus.internals + 'id'),
'text': post.find(Disqus.ns + 'message').text,
'author': post.find('{0}author/{0}name'.format(Disqus.ns)).text,
'email': email.text if email is not None else '',
'email': post.find('{0}author/{0}email'.format(Disqus.ns)).text,
'created': mktime(strptime(
post.find(Disqus.ns + 'createdAt').text, '%Y-%m-%dT%H:%M:%SZ')),
'remote_addr': anonymize(ip.text if ip is not None else '0.0.0.0'),
'remote_addr': anonymize(post.find(Disqus.ns + 'ipAddress').text),
'mode': 1 if post.find(Disqus.ns + "isDeleted").text == "false" else 4
}
@ -152,11 +149,10 @@ class Disqus(object):
if post.attrib.get(Disqus.internals + "id") not in orphans:
continue
email = post.find("{0}author/{0}email".format(Disqus.ns))
print(" * {0} by {1} <{2}>".format(
post.attrib.get(Disqus.internals + "id"),
post.find("{0}author/{0}name".format(Disqus.ns)).text,
email.text if email is not None else ""))
post.find("{0}author/{0}email".format(Disqus.ns)).text))
print(textwrap.fill(post.find(Disqus.ns + "message").text,
initial_indent=" ", subsequent_indent=" "))
print("")
@ -251,81 +247,7 @@ class WordPress(object):
@classmethod
def detect(cls, peek):
return re.compile("http://wordpress.org/export/(1\\.\\d)/").search(peek)
class Generic(object):
"""A generic importer.
The source format is a json with the following format:
A list of threads, each item being a dict with the following data:
- id: a text representing the unique thread id
- title: the title of the thread
- comments: the list of comments
Each item in that list of comments is a dict with the following data:
- id: an integer with the unique id of the comment inside the thread (it can be repeated
among different threads); this will be used to order the comment inside the thread
- author: the author's name
- email: the author's email
- website: the author's website
- remote_addr: the author's IP
- created: a timestamp, in the format "%Y-%m-%d %H:%M:%S"
"""
def __init__(self, db, json_file):
self.db = db
self.json_file = json_file
self.count = 0
def insert(self, thread):
"""Process a thread and insert its comments in the DB."""
thread_id = thread['id']
title = thread['title']
self.db.threads.new(thread_id, title)
comments = list(map(self._build_comment, thread['comments']))
comments.sort(key=lambda comment: comment['id'])
self.count += len(comments)
for comment in comments:
self.db.comments.add(thread_id, comment)
def migrate(self):
"""Process the input file and fill the DB."""
with io.open(self.json_file, 'rt', encoding='utf8') as fh:
threads = json.load(fh)
progress = Progress(len(threads))
for i, thread in enumerate(threads):
progress.update(i, str(i))
self.insert(thread)
progress.finish("{0} threads, {1} comments".format(len(threads), self.count))
def _build_comment(self, raw_comment):
return {
"text": raw_comment['text'],
"author": raw_comment['author'],
"email": raw_comment['email'],
"website": raw_comment['website'],
"created": mktime(strptime(raw_comment['created'], "%Y-%m-%d %H:%M:%S")),
"mode": 1,
"id": int(raw_comment['id']),
"parent": None,
"remote_addr": raw_comment["remote_addr"],
}
@classmethod
def detect(cls, peek):
"""Return if peek looks like the beginning of a JSON file.
Note that we can not check the JSON properly as we only receive here
the original file truncated.
"""
return peek.startswith("[{")
return re.compile("http://wordpress.org/export/(1\.\d)/").search(peek)
def autodetect(peek):
@ -337,9 +259,6 @@ def autodetect(peek):
if m:
return WordPress
if Generic.detect(peek):
return Generic
return None
@ -352,8 +271,6 @@ def dispatch(type, db, dump, empty_id=False):
cls = Disqus
elif type == "wordpress":
cls = WordPress
elif type == "generic":
cls = Generic
else:
with io.open(dump, encoding="utf-8") as fp:
cls = autodetect(fp.read(io.DEFAULT_BUFFER_SIZE))

@ -1,15 +1,115 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="{{isso_host_script}}/css/isso.css" rel="stylesheet">
<link type="text/css" href="{{isso_host_script}}/css/admin.css" rel="stylesheet">
<script type="text/javascript" src="{{isso_host_script}}/js/admin.js"></script>
<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="{{isso_host_script}}/img/isso.svg" alt="Wynaut by @veekun"/>
<img class="logo" src="/img/isso.svg" alt="Wynaut by @veekun"/>
<div class="title">
<a href="./">
<h1>Isso</h1>
@ -72,7 +172,7 @@
{% endfor %}
</div>
</div>
<div id="isso-root">
<main>
{% set thread_id = "no_id" %}
{% for comment in comments %}
{% if order_by == "tid" %}
@ -132,22 +232,22 @@
<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}}','{{isso_host_script}}')">Send</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}}', '{{isso_host_script}}')">
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}}', '{{isso_host_script}}')">Validate</a>
onClick="javascript:validate_com({{comment.id}}, '{{comment.hash}}')">Validate</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</main>
</div>
</body>
</html>

@ -1,28 +0,0 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="{{isso_host_script}}/css/isso.css" rel="stylesheet">
<link type="text/css" href="{{isso_host_script}}/css/admin.css" rel="stylesheet">
</head>
<body>
<div class="wrapper">
<div class="header">
<header>
<img class="logo" src="{{isso_host_script}}/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="disabled">
Administration is disabled on this instance of isso. Set enabled=true
in the admin section of your isso configuration to enable it.
</div>
</main>
</div>
</body>
</html>

@ -1,14 +1,14 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="{{isso_host_script}}/css/isso.css" rel="stylesheet">
<link type="text/css" href="{{isso_host_script}}/css/admin.css" rel="stylesheet">
<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="{{isso_host_script}}/img/isso.svg" alt="Wynaut by @veekun"/>
<img class="logo" src="/img/isso.svg" alt="Wynaut by @veekun"/>
<div class="title">
<a href="./">
<h1>Isso</h1>
@ -20,8 +20,8 @@
<main>
<div id="login">
Administration secured by password:
<form method="POST" action="{{isso_host_script}}/login">
<input type="password" name="password" autofocus />
<form method="POST" action="/login">
<input type="password" name="password" />
</form>
</div>
</main>

@ -37,9 +37,7 @@ class Dummy:
pass
def curl(method, host, path):
return Dummy()
def curl(method, host, path): return Dummy()
def loads(data):
return json.loads(data.decode('utf-8'))
def loads(data): return json.loads(data.decode('utf-8'))

@ -1 +0,0 @@
[{"comments": [{"email": "", "remote_addr": "0.0.0.0", "website": "http://www.tigerspice.com", "created": "2005-02-24 04:03:37", "author": "texas holdem", "id": 0, "text": "Great men can't be ruled. by free online poker"}], "id": "/posts/0001/", "title": "Test+post"}, {"comments": [{"email": "105421439@87750645.com", "remote_addr": "0.0.0.0", "website": "", "created": "2005-05-08 06:50:26", "author": "Richard Crinshaw", "id": 0, "text": "Ja-make-a me crazzy mon :)\n"}], "id": "/posts/0007/", "title": "Nat+%26+Miguel"}]

@ -4,7 +4,6 @@ from __future__ import unicode_literals
import os
import json
import re
import tempfile
import unittest
@ -33,8 +32,6 @@ class TestComments(unittest.TestCase):
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none")
conf.set("general", "latest-enabled", "true")
self.conf = conf
class App(Isso, core.Mixin):
pass
@ -120,8 +117,7 @@ class TestComments(unittest.TestCase):
def testVerifyFields(self):
def verify(comment):
return comments.API.verify(comment)[0]
def verify(comment): return comments.API.verify(comment)[0]
# text is missing
self.assertFalse(verify({}))
@ -136,13 +132,13 @@ class TestComments(unittest.TestCase):
self.assertFalse(verify({"text": text}))
# email/website length
self.assertTrue(verify({"text": "...", "email": "*" * 254}))
self.assertTrue(verify({"text": "...", "email": "*"*254}))
self.assertTrue(
verify({"text": "...", "website": "google.de/" + "a" * 128}))
verify({"text": "...", "website": "google.de/" + "a"*128}))
self.assertFalse(verify({"text": "...", "email": "*" * 1024}))
self.assertFalse(verify({"text": "...", "email": "*"*1024}))
self.assertFalse(
verify({"text": "...", "website": "google.de/" + "*" * 1024}))
verify({"text": "...", "website": "google.de/" + "*"*1024}))
# valid website url
self.assertTrue(comments.isurl("example.tld"))
@ -160,18 +156,10 @@ class TestComments(unittest.TestCase):
def testGetInvalid(self):
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=123').status_code, 200)
data = loads(self.get('/?uri=%2Fpath%2F&id=123').data)
self.assertEqual(len(data['replies']), 0)
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=123').status_code, 404)
self.assertEqual(
self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 200)
data = loads(self.get('/?uri=%2Fpath%2Fspam%2F&id=123').data)
self.assertEqual(len(data['replies']), 0)
self.assertEqual(self.get('/?uri=?uri=%foo%2F').status_code, 200)
data = loads(self.get('/?uri=?uri=%foo%2F').data)
self.assertEqual(len(data['replies']), 0)
self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404)
self.assertEqual(self.get('/?uri=?uri=%foo%2F').status_code, 404)
def testGetLimited(self):
@ -253,12 +241,9 @@ class TestComments(unittest.TestCase):
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=2').status_code, 200)
r = client.delete('/id/2')
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 200)
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 404)
self.assertNotIn('/path/', self.app.db.threads)
data = loads(client.get('/?uri=%2Fpath%2F').data)
self.assertEqual(len(data['replies']), 0)
def testDeleteWithMultipleReferences(self):
"""
[ comment 1 ]
@ -284,10 +269,7 @@ class TestComments(unittest.TestCase):
client.delete('/id/3')
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 200)
client.delete('/id/4')
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 200)
data = loads(client.get('/?uri=%2Fpath%2F').data)
self.assertEqual(len(data['replies']), 0)
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 404)
def testPathVariations(self):
@ -338,44 +320,10 @@ class TestComments(unittest.TestCase):
rv = loads(rv.data)
for key in comments.API.FIELDS:
if key in rv:
rv.pop(key)
rv.pop(key)
self.assertListEqual(list(rv.keys()), [])
def testNoFeed(self):
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
self.assertEqual(rv.status_code, 404)
def testFeedEmpty(self):
self.conf.set("rss", "base", "https://example.org")
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers['ETag'], '"empty"')
data = rv.data.decode('utf-8')
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>1970-01-01T01:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/nothing</id><title>Comments for example.org/path/nothing</title></feed>""")
def testFeed(self):
self.conf.set("rss", "base", "https://example.org")
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': '*Second*', 'parent': 1}))
rv = self.get('/feed?uri=%2Fpath%2F')
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers['ETag'], '"1-2"')
data = rv.data.decode('utf-8')
data = re.sub('[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+Z',
'2018-04-01T10:00:00Z', data)
self.maxDiff = None
# Two accepted outputs, since different versions of Python sort attributes in different order.
self.assertIn(data, ["""<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>2018-04-01T10:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/</id><title>Comments for example.org/path/</title><entry><id>tag:example.org,2018:/isso/1/2</id><title>Comment #2</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-2" /><content type="html">&lt;p&gt;&lt;em&gt;Second&lt;/em&gt;&lt;/p&gt;</content><thr:in-reply-to href="https://example.org/path/#isso-1" ref="tag:example.org,2018:/isso/1/1" /></entry><entry><id>tag:example.org,2018:/isso/1/1</id><title>Comment #1</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-1" /><content type="html">&lt;p&gt;First&lt;/p&gt;</content></entry></feed>""", """<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>2018-04-01T10:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/</id><title>Comments for example.org/path/</title><entry><id>tag:example.org,2018:/isso/1/2</id><title>Comment #2</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-2" /><content type="html">&lt;p&gt;&lt;em&gt;Second&lt;/em&gt;&lt;/p&gt;</content><thr:in-reply-to ref="tag:example.org,2018:/isso/1/1" href="https://example.org/path/#isso-1" /></entry><entry><id>tag:example.org,2018:/isso/1/1</id><title>Comment #1</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-1" /><content type="html">&lt;p&gt;First&lt;/p&gt;</content></entry></feed>"""])
def testCounts(self):
self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404)
@ -455,48 +403,6 @@ class TestComments(unittest.TestCase):
self.assertEqual(
rv["text"], '<p>This is <strong>mark</strong><em>down</em></p>')
def testLatestOk(self):
# load some comments in a mix of posts
saved = []
for idx, post_id in enumerate([1, 2, 2, 1, 2, 1, 3, 1, 4, 2, 3, 4, 1, 2]):
text = 'text-{}'.format(idx)
post_uri = 'test-{}'.format(post_id)
self.post('/new?uri=' + post_uri, data=json.dumps({'text': text}))
saved.append((post_uri, text))
response = self.get('/latest?limit=5')
self.assertEqual(response.status_code, 200)
body = loads(response.data)
expected_items = saved[-5:] # latest 5
for reply, expected in zip(body, expected_items):
expected_uri, expected_text = expected
self.assertIn(expected_text, reply['text'])
self.assertEqual(expected_uri, reply['uri'])
def testLatestWithoutLimit(self):
response = self.get('/latest')
self.assertEqual(response.status_code, 400)
def testLatestBadLimitNaN(self):
response = self.get('/latest?limit=WAT')
self.assertEqual(response.status_code, 400)
def testLatestBadLimitNegative(self):
response = self.get('/latest?limit=-12')
self.assertEqual(response.status_code, 400)
def testLatestBadLimitZero(self):
response = self.get('/latest?limit=0')
self.assertEqual(response.status_code, 400)
def testLatestNotEnabled(self):
# disable the endpoint
self.conf.set("general", "latest-enabled", "false")
response = self.get('/latest?limit=5')
self.assertEqual(response.status_code, 404)
class TestModeratedComments(unittest.TestCase):
@ -525,10 +431,7 @@ class TestModeratedComments(unittest.TestCase):
self.assertEqual(rv.status_code, 202)
self.assertEqual(self.client.get('/id/1').status_code, 200)
self.assertEqual(self.client.get('/?uri=test').status_code, 200)
data = loads(self.client.get('/?uri=test').data)
self.assertEqual(len(data['replies']), 0)
self.assertEqual(self.client.get('/?uri=test').status_code, 404)
self.app.db.comments.activate(1)
self.assertEqual(self.client.get('/?uri=test').status_code, 200)

@ -97,8 +97,7 @@ class TestGuard(unittest.TestCase):
def testSelfReply(self):
def payload(id):
return json.dumps({"text": "...", "parent": id})
def payload(id): return json.dumps({"text": "...", "parent": id})
client = self.makeClient("127.0.0.1", self_reply=False)
self.assertEqual(client.post(
@ -125,8 +124,7 @@ class TestGuard(unittest.TestCase):
def testRequireEmail(self):
def payload(email):
return json.dumps({"text": "...", "email": email})
def payload(email): return json.dumps({"text": "...", "email": email})
client = self.makeClient("127.0.0.1", ratelimit=4, require_email=False)
client_strict = self.makeClient(
@ -146,8 +144,8 @@ class TestGuard(unittest.TestCase):
def testRequireAuthor(self):
def payload(author):
return json.dumps({"text": "...", "author": author})
def payload(author): return json.dumps(
{"text": "...", "author": author})
client = self.makeClient(
"127.0.0.1", ratelimit=4, require_author=False)

@ -29,7 +29,7 @@ class TestHTML(unittest.TestCase):
self.assertEqual(convert(input), expected)
def test_github_flavoured_markdown(self):
convert = html.Markdown(extensions=("fenced-code", ))
convert = html.Markdown(extensions=("fenced_code", ))
# without lang
_in = textwrap.dedent("""\
@ -59,23 +59,21 @@ class TestHTML(unittest.TestCase):
print("Hello, World")
</code></pre>""")
@unittest.skipIf(html.HTML5LIB_VERSION <= html.HTML5LIB_SIMPLETREE, "backport")
def test_sanitizer(self):
sanitizer = html.Sanitizer(elements=[], attributes=[])
examples = [
('Look: <img src="..." />', 'Look: '),
('<a href="http://example.org/">Ha</a>',
['<a href="http://example.org/" rel="nofollow noopener">Ha</a>',
'<a rel="nofollow noopener" href="http://example.org/">Ha</a>']),
'<a href="http://example.org/">Ha</a>'),
('<a href="sms:+1234567890">Ha</a>', '<a>Ha</a>'),
('<p style="visibility: hidden;">Test</p>', '<p>Test</p>'),
('<script>alert("Onoe")</script>', 'alert("Onoe")')]
for (input, expected) in examples:
if isinstance(expected, list):
self.assertIn(sanitizer.sanitize(input), expected)
else:
self.assertEqual(sanitizer.sanitize(input), expected)
self.assertEqual(html.sanitize(sanitizer, input), expected)
@unittest.skipIf(html.HTML5LIB_VERSION <= html.HTML5LIB_SIMPLETREE, "backport")
def test_sanitizer_extensions(self):
sanitizer = html.Sanitizer(elements=["img"], attributes=["src"])
examples = [
@ -83,18 +81,16 @@ class TestHTML(unittest.TestCase):
('<script src="doge.js"></script>', '')]
for (input, expected) in examples:
self.assertEqual(sanitizer.sanitize(input), expected)
self.assertEqual(html.sanitize(sanitizer, input), expected)
def test_render(self):
conf = config.new({
"markup": {
"options": "autolink",
"flags": "",
"allowed-elements": "",
"allowed-attributes": ""
}
})
renderer = html.Markup(conf.section("markup")).render
self.assertIn(renderer("http://example.org/ and sms:+1234567890"),
['<p><a href="http://example.org/" rel="nofollow noopener">http://example.org/</a> and sms:+1234567890</p>',
'<p><a rel="nofollow noopener" href="http://example.org/">http://example.org/</a> and sms:+1234567890</p>'])
self.assertEqual(renderer("http://example.org/ and sms:+1234567890"),
'<p><a href="http://example.org/">http://example.org/</a> and sms:+1234567890</p>')

@ -9,7 +9,7 @@ from os.path import join, dirname
from isso import config
from isso.db import SQLite3
from isso.migrate import Disqus, WordPress, autodetect, Generic
from isso.migrate import Disqus, WordPress, autodetect
conf = config.new({
"general": {
@ -79,38 +79,6 @@ class TestMigration(unittest.TestCase):
self.assertEqual(last["author"], "Letzter :/")
self.assertEqual(last["parent"], None)
def test_generic(self):
filepath = join(dirname(__file__), "generic.json")
tempf = tempfile.NamedTemporaryFile()
db = SQLite3(tempf.name, conf)
Generic(db, filepath).migrate()
self.assertEqual(db.threads["/posts/0001/"]["title"], "Test+post")
self.assertEqual(db.threads["/posts/0001/"]["id"], 1)
self.assertEqual(db.threads["/posts/0007/"]["title"], "Nat+%26+Miguel")
self.assertEqual(db.threads["/posts/0007/"]["id"], 2)
self.assertEqual(
len(db.execute("SELECT id FROM threads").fetchall()), 2)
self.assertEqual(
len(db.execute("SELECT id FROM comments").fetchall()), 2)
comment = db.comments.get(1)
self.assertEqual(comment["author"], "texas holdem")
self.assertEqual(comment["text"], "Great men can't be ruled. by free online poker")
self.assertEqual(comment["email"], "")
self.assertEqual(comment["website"], "http://www.tigerspice.com")
self.assertEqual(comment["remote_addr"], "0.0.0.0")
comment = db.comments.get(2)
self.assertEqual(comment["author"], "Richard Crinshaw")
self.assertEqual(comment["text"], "Ja-make-a me crazzy mon :)\n")
self.assertEqual(comment["email"], "105421439@87750645.com")
self.assertEqual(comment["website"], "")
self.assertEqual(comment["remote_addr"], "0.0.0.0")
def test_detection(self):
wp = """\
@ -130,6 +98,3 @@ class TestMigration(unittest.TestCase):
<disqus xmlns="http://disqus.com"
xmlns:dsq="http://disqus.com/disqus-internals"'''
self.assertEqual(autodetect(dq), Disqus)
jf = '[{"comments": [{"email": "", "remote_addr": "0.0.0.0", '
self.assertEqual(autodetect(jf), Generic)

@ -19,18 +19,6 @@ class TestUtils(unittest.TestCase):
for (addr, anonymized) in examples:
self.assertEqual(utils.anonymize(addr), anonymized)
def test_str(self):
# Accept a str on both Python 2 and Python 3, for
# convenience.
examples = [
('12.34.56.78', u'12.34.56.0'),
('1234:5678:90ab:cdef:fedc:ba09:8765:4321',
'1234:5678:90ab:0000:0000:0000:0000:0000'),
('::ffff:127.0.0.1', u'127.0.0.0')]
for (addr, anonymized) in examples:
self.assertEqual(utils.anonymize(addr), anonymized)
class TestParse(unittest.TestCase):

@ -10,10 +10,10 @@ class TestWSGIUtilities(unittest.TestCase):
def test_urlsplit(self):
examples = [
("http://example.tld/", ('example.tld', 80, False)),
("http://example.tld/", ('example.tld', 80, False)),
("https://example.tld/", ('example.tld', 443, True)),
("example.tld", ('example.tld', 80, False)),
("example.tld:42", ('example.tld', 42, False)),
("example.tld", ('example.tld', 80, False)),
("example.tld:42", ('example.tld', 42, False)),
("https://example.tld:80/", ('example.tld', 80, True))]
for (hostname, result) in examples:
@ -23,7 +23,7 @@ class TestWSGIUtilities(unittest.TestCase):
examples = [
(("example.tld", 80, False), "http://example.tld"),
(("example.tld", 42, True), "https://example.tld:42"),
(("example.tld", 42, True), "https://example.tld:42"),
(("example.tld", 443, True), "https://example.tld")]
for (split, result) in examples:

@ -14,7 +14,6 @@ from jinja2 import Environment, FileSystemLoader
from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest
from isso.compat import text_type
from isso.wsgi import Request
try:
@ -29,8 +28,6 @@ def anonymize(remote_addr):
and /48 (zero'd).
"""
if not isinstance(remote_addr, text_type) and isinstance(remote_addr, str):
remote_addr = remote_addr.decode('ascii', 'ignore')
try:
ipv4 = ipaddress.IPv4Address(remote_addr)
return u''.join(ipv4.exploded.rsplit('.', 1)[0]) + '.' + '0'
@ -38,8 +35,8 @@ def anonymize(remote_addr):
try:
ipv6 = ipaddress.IPv6Address(remote_addr)
if ipv6.ipv4_mapped is not None:
return anonymize(text_type(ipv6.ipv4_mapped))
return u'' + ipv6.exploded.rsplit(':', 5)[0] + ':' + ':'.join(['0000'] * 5)
return anonymize(ipv6.ipv4_mapped)
return u'' + ipv6.exploded.rsplit(':', 5)[0] + ':' + ':'.join(['0000']*5)
except ipaddress.AddressValueError:
return u'0.0.0.0'
@ -92,11 +89,11 @@ class Bloomfilter:
def add(self, key):
for i in self.get_probes(key):
self.array[i // 8] |= 2 ** (i % 8)
self.array[i//8] |= 2 ** (i % 8)
self.elements += 1
def __contains__(self, key):
return all(self.array[i // 8] & (2 ** (i % 8)) for i in self.get_probes(key))
return all(self.array[i//8] & (2 ** (i % 8)) for i in self.get_probes(key))
def __len__(self):
return self.elements
@ -135,10 +132,3 @@ class JSONResponse(Response):
kwargs["content_type"] = "application/json"
super(JSONResponse, self).__init__(
json.dumps(obj).encode("utf-8"), *args, **kwargs)
class XMLResponse(Response):
def __init__(self, obj, *args, **kwargs):
kwargs["content_type"] = "text/xml"
super(XMLResponse, self).__init__(
obj, *args, **kwargs)

@ -15,8 +15,8 @@ except ImportError:
def pbkdf2(val, salt, iterations, dklen, func):
return _pbkdf2(val, salt, iterations, dklen, ("hmac-" + func).encode("utf-8"))
except ImportError:
raise ImportError("No PBKDF2 implementation found. Either upgrade "
except ImportError as ex:
raise ImportError("No PBKDF2 implementation found. Either upgrade " +
"to `werkzeug` 0.9 or install `passlib`.")
@ -110,4 +110,3 @@ def new(conf):
sha1 = Hash(func="sha1").uhash
md5 = Hash(func="md5").uhash

@ -2,65 +2,72 @@
from __future__ import unicode_literals
import bleach
import operator
import pkg_resources
from distutils.version import LooseVersion as Version
HTML5LIB_VERSION = Version(pkg_resources.get_distribution("html5lib").version)
HTML5LIB_SIMPLETREE = Version("0.95")
from isso.compat import reduce
import html5lib
from html5lib.sanitizer import HTMLSanitizer
from html5lib.serializer import HTMLSerializer
import misaka
try:
from backports.configparser import NoOptionError
except ImportError:
from configparser import NoOptionError
def Sanitizer(elements, attributes):
class Sanitizer(object):
class Inner(HTMLSanitizer):
def __init__(self, elements, attributes):
# attributes found in Sundown's HTML serializer [1]
# except for <img> tag,
# attributes found in Sundown's HTML serializer [1] except for <img> tag,
# because images are not generated anyways.
#
# [1] https://github.com/vmg/sundown/blob/master/html/html.c
self.elements = ["a", "p", "hr", "br", "ol", "ul", "li",
"pre", "code", "blockquote",
"del", "ins", "strong", "em",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "th", "td"] + elements
allowed_elements = ["a", "p", "hr", "br", "ol", "ul", "li",
"pre", "code", "blockquote",
"del", "ins", "strong", "em",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "th", "td"] + elements
# href for <a> and align for <table>
self.attributes = ["align", "href"] + attributes
allowed_attributes = ["align", "href"] + attributes
# remove disallowed tokens from the output
def disallowed_token(self, token, token_type):
return None
def sanitize(self, text):
clean_html = bleach.clean(text, tags=self.elements, attributes=self.attributes, strip=True)
return Inner
def set_links(attrs, new=False):
href_key = (None, u'href')
if href_key not in attrs:
return attrs
if attrs[href_key].startswith(u'mailto:'):
return attrs
def sanitize(tokenizer, document):
rel_key = (None, u'rel')
rel_values = [val for val in attrs.get(rel_key, u'').split(u' ') if val]
parser = html5lib.HTMLParser(tokenizer=tokenizer)
domtree = parser.parseFragment(document)
for value in [u'nofollow', u'noopener']:
if value not in [rel_val.lower() for rel_val in rel_values]:
rel_values.append(value)
if HTML5LIB_VERSION > HTML5LIB_SIMPLETREE:
builder = "etree"
else:
builder = "simpletree"
attrs[rel_key] = u' '.join(rel_values)
return attrs
stream = html5lib.treewalkers.getTreeWalker(builder)(domtree)
serializer = HTMLSerializer(
quote_attr_values=True, omit_optional_tags=False)
linker = bleach.linkifier.Linker(callbacks=[set_links])
return linker.linkify(clean_html)
return serializer.render(stream)
def Markdown(extensions=("strikethrough", "superscript", "autolink",
"fenced-code"), flags=[]):
def Markdown(extensions=("strikethrough", "superscript", "autolink")):
renderer = Unofficial(flags=flags)
md = misaka.Markdown(renderer, extensions=extensions)
flags = reduce(operator.xor, map(
lambda ext: getattr(misaka, 'EXT_' + ext.upper()), extensions), 0)
md = misaka.Markdown(Unofficial(), extensions=flags)
def inner(text):
rv = md(text).rstrip("\n")
rv = md.render(text).rstrip("\n")
if rv.startswith("<p>") or rv.endswith("</p>"):
return rv
return "<p>" + rv + "</p>"
@ -76,7 +83,7 @@ class Unofficial(misaka.HtmlRenderer):
to <code class="$lang">, compatible with Highlight.js.
"""
def blockcode(self, text, lang):
def block_code(self, text, lang):
lang = ' class="{0}"'.format(lang) if lang else ''
return "<pre><code{1}>{0}</code></pre>\n".format(text, lang)
@ -85,16 +92,12 @@ class Markup(object):
def __init__(self, conf):
try:
conf_flags = conf.getlist("flags")
except NoOptionError:
conf_flags = []
parser = Markdown(extensions=conf.getlist("options"), flags=conf_flags)
parser = Markdown(conf.getlist("options"))
sanitizer = Sanitizer(
conf.getlist("allowed-elements"),
conf.getlist("allowed-attributes"))
self._render = lambda text: sanitizer.sanitize(parser(text))
self._render = lambda text: sanitize(sanitizer, parser(text))
def render(self, text):
return self._render(text)

@ -2,15 +2,13 @@
from __future__ import unicode_literals
import collections
import re
import cgi
import time
import functools
import json # json.dumps to put URL in <script>
from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET
from werkzeug.http import dump_cookie
from werkzeug.wsgi import get_current_url
@ -22,30 +20,10 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str
from isso import utils, local
from isso.utils import (http, parse,
JSONResponse as JSON, XMLResponse as XML,
from isso.utils import (http, parse, JSONResponse as JSON,
render_template)
from isso.views import requires
from isso.utils.hash import sha1
from isso.utils.hash import md5
try:
from cgi import escape
except ImportError:
from html import escape
try:
from urlparse import urlparse
except ImportError:
from urllib.parse import urlparse
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
try:
from StringIO import StringIO
except ImportError:
from io import BytesIO as StringIO
# from Django appearently, looks good to me *duck*
__url_re = re.compile(
@ -103,30 +81,27 @@ def xhr(func):
class API(object):
FIELDS = set(['id', 'parent', 'text', 'author', 'website',
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification'])
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash'])
# comment fields, that can be submitted
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification'])
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title'])
VIEWS = [
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('latest', ('GET', '/latest')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('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')),
('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')),
('demo', ('GET', '/demo')),
('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
]
def __init__(self, isso, hasher):
@ -138,9 +113,6 @@ class API(object):
self.conf = isso.conf.section("general")
self.moderated = isso.conf.getboolean("moderation", "enabled")
# this is similar to the wordpress setting "Comment author must have a previously approved comment"
self.approve_if_email_previously_approved = isso.conf.getboolean("moderation", "approve-if-email-previously-approved")
self.trusted_proxies = list(isso.conf.getiter("server", "trusted-proxies"))
self.guard = isso.db.guard
self.threads = isso.db.threads
@ -270,13 +242,13 @@ class API(object):
for field in ("author", "email", "website"):
if data.get(field) is not None:
data[field] = escape(data[field], quote=False)
data[field] = cgi.escape(data[field])
if data.get("website"):
data["website"] = normalize(data["website"])
data['mode'] = 2 if self.moderated else 1
data['remote_addr'] = self._remote_addr(request)
data['remote_addr'] = utils.anonymize(str(request.remote_addr))
with self.isso.lock:
if uri not in self.threads:
@ -303,11 +275,6 @@ class API(object):
raise Forbidden(reason)
with self.isso.lock:
# if email-based auto-moderation enabled, check for previously approved author
# right before approval.
if self.approve_if_email_previously_approved and self.comments.is_previously_approved_author(data['email']):
data['mode'] = 1
rv = self.comments.add(uri, data)
# notify extension, that the new comment has been successfully saved
@ -324,8 +291,6 @@ class API(object):
self.cache.set(
'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
rv = self._add_gravatar_image(rv)
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
@ -337,21 +302,6 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
def _remote_addr(self, request):
"""Return the anonymized IP address of the requester.
Takes into consideration a potential X-Forwarded-For HTTP header
if a necessary server.trusted-proxies configuration entry is set.
Recipe source: https://stackoverflow.com/a/22936947/636849
"""
remote_addr = request.remote_addr
if self.trusted_proxies:
route = request.access_route + [remote_addr]
remote_addr = next((addr for addr in reversed(route)
if addr not in self.trusted_proxies), remote_addr)
return utils.anonymize(str(remote_addr))
"""
@api {get} /id/:id view
@apiGroup Comment
@ -527,70 +477,6 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
"""
@api {get} /id/:id/:email/key unsubscribe
@apiGroup Comment
@apiDescription
Opt out from getting any further email notifications about replies to a particular comment. In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.
@apiParam {number} id
The id of the comment to unsubscribe from replies to.
@apiParam {string} email
The email address of the subscriber.
@apiParam {string} key
The key to authenticate the subscriber.
@apiExample {curl} Unsubscribe Alice from replies to comment with id 13:
curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos'
@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 unsubscribe(self, environ, request, id, email, key):
email = unquote(email)
try:
rv = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
if rv[0] != 'unsubscribe' or rv[1] != email:
raise Forbidden
item = self.comments.get(id)
if item is None:
raise NotFound
with self.isso.lock:
self.comments.unsubscribe(email, id)
modal = (
"<!DOCTYPE html>"
"<html>"
"<head>"
" <title>Successfully unsubscribed</title>"
"</head>"
"<body>"
" <p>You have been unsubscribed from replies in the given conversation.</p>"
"</body>"
"</html>")
return Response(modal, 200, content_type="text/html")
"""
@api {post} /id/:id/:action/key moderate
@apiGroup Comment
@ -619,9 +505,6 @@ class API(object):
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
xhr.onload = function() {
window.location.href = "https://example.com/example-thread/#isso-13";
};
}
&lt;/script&gt;
@ -636,8 +519,6 @@ class API(object):
raise Forbidden
item = self.comments.get(id)
thread = self.threads.get(item['tid'])
link = local("origin") + thread["uri"] + "#isso-%i" % item["id"]
if item is None:
raise NotFound
@ -652,20 +533,15 @@ class API(object):
" xhr = new XMLHttpRequest;"
" xhr.open('POST', window.location.href);"
" xhr.send(null);"
" xhr.onload = function() {"
" window.location.href = %s;"
" };"
" }"
"</script>" % (action.capitalize(), json.dumps(link)))
"</script>" % action.capitalize())
return Response(modal, 200, content_type="text/html")
if action == "activate":
if item['mode'] == 1:
return Response("Already activated", 200)
with self.isso.lock:
self.comments.activate(id)
self.signal("comments.activate", thread, item)
self.signal("comments.activate", id)
return Response("Yo", 200)
elif action == "edit":
data = request.get_json()
@ -798,6 +674,8 @@ class API(object):
root_list = []
else:
root_list = list(self.comments.fetch(**args))
if not root_list:
raise NotFound
if root_id not in reply_counts:
reply_counts[root_id] = 0
@ -840,18 +718,6 @@ class API(object):
return JSON(rv, 200)
def _add_gravatar_image(self, item):
if not self.conf.getboolean('gravatar'):
return item
email = item['email'] or item['author'] or ''
email_md5_hash = md5(email)
gravatar_url = self.conf.get('gravatar-url')
item['gravatar_image'] = gravatar_url.format(email_md5_hash)
return item
def _process_fetched_list(self, fetched_list, plain=False):
for item in fetched_list:
@ -864,8 +730,6 @@ class API(object):
item['hash'] = val
item = self._add_gravatar_image(item)
for key in set(item.keys()) - API.FIELDS:
item.pop(key)
@ -906,7 +770,8 @@ class API(object):
@xhr
def like(self, environ, request, id):
nv = self.comments.vote(True, id, self._remote_addr(request))
nv = self.comments.vote(
True, id, utils.anonymize(str(request.remote_addr)))
return JSON(nv, 200)
"""
@ -932,7 +797,8 @@ class API(object):
@xhr
def dislike(self, environ, request, id):
nv = self.comments.vote(False, id, self._remote_addr(request))
nv = self.comments.vote(
False, id, utils.anonymize(str(request.remote_addr)))
return JSON(nv, 200)
# TODO: remove someday (replaced by :func:`counts`)
@ -958,6 +824,7 @@ class API(object):
@apiSuccessExample Counts of 5 threads:
[2, 18, 4, 0, 3]
"""
def counts(self, environ, request):
data = request.get_json()
@ -967,125 +834,6 @@ class API(object):
return JSON(self.comments.count(*data), 200)
"""
@api {get} /feed Atom feed for comments
@apiGroup Thread
@apiDescription
Provide an Atom feed for the given thread.
"""
@requires(str, 'uri')
def feed(self, environ, request, uri):
conf = self.isso.conf.section("rss")
if not conf.get('base'):
raise NotFound
args = {
'uri': uri,
'order_by': 'id',
'asc': 0,
'limit': conf.getint('limit')
}
try:
args['limit'] = max(int(request.args.get('limit')), args['limit'])
except TypeError:
pass
except ValueError:
return BadRequest("limit should be integer")
comments = self.comments.fetch(**args)
base = conf.get('base').rstrip('/')
hostname = urlparse(base).netloc
# Let's build an Atom feed.
# RFC 4287: https://tools.ietf.org/html/rfc4287
# RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions)
# For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
feed = ET.Element('feed', {
'xmlns': 'http://www.w3.org/2005/Atom',
'xmlns:thr': 'http://purl.org/syndication/thread/1.0'
})
# For feed ID, we would use thread ID, but we may not have
# one. Therefore, we use the URI. We don't have a year
# either...
id = ET.SubElement(feed, 'id')
id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format(
hostname=hostname, uri=uri)
# For title, we don't have much either. Be pretty generic.
title = ET.SubElement(feed, 'title')
title.text = 'Comments for {hostname}{uri}'.format(
hostname=hostname, uri=uri)
comment0 = None
for comment in comments:
if comment0 is None:
comment0 = comment
entry = ET.SubElement(feed, 'entry')
# We don't use a real date in ID either to help with
# threading.
id = ET.SubElement(entry, 'id')
id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['id'])
title = ET.SubElement(entry, 'title')
title.text = 'Comment #{}'.format(comment['id'])
updated = ET.SubElement(entry, 'updated')
updated.text = '{}Z'.format(datetime.fromtimestamp(
comment['modified'] or comment['created']).isoformat())
author = ET.SubElement(entry, 'author')
name = ET.SubElement(author, 'name')
name.text = comment['author']
ET.SubElement(entry, 'link', {
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['id'])
})
content = ET.SubElement(entry, 'content', {
'type': 'html',
})
content.text = self.isso.render(comment['text'])
if comment['parent']:
ET.SubElement(entry, 'thr:in-reply-to', {
'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['parent']),
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['parent'])
})
# Updated is mandatory. If we have comments, we use the date
# of last modification of the first one (which is the last
# one). Otherwise, we use a fixed date.
updated = ET.Element('updated')
if comment0 is None:
updated.text = '1970-01-01T01:00:00Z'
else:
updated.text = datetime.fromtimestamp(
comment0['modified'] or comment0['created']).isoformat()
updated.text += 'Z'
feed.insert(0, updated)
output = StringIO()
ET.ElementTree(feed).write(output,
encoding='utf-8',
xml_declaration=True)
response = XML(output.getvalue(), 200)
# Add an etag/last-modified value for caching purpose
if comment0 is None:
response.set_etag('empty')
response.last_modified = 0
else:
response.set_etag('{tid}-{id}'.format(**comment0))
response.last_modified = comment0['modified'] or comment0['created']
return response.make_conditional(request)
def preview(self, environment, request):
data = request.get_json()
@ -1095,22 +843,14 @@ class API(object):
return JSON({'text': self.isso.render(data["text"])}, 200)
def demo(self, env, req):
return redirect(
get_current_url(env, strip_querystring=True) + '/index.html'
)
return redirect(get_current_url(env) + '/index.html')
def login(self, env, req):
if not self.isso.conf.getboolean("admin", "enabled"):
isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
return render_template('disabled.html', isso_host_script=isso_host_script)
data = req.form
password = self.isso.conf.get("admin", "password")
password = self.isso.conf.get("general", "admin_password")
if data['password'] and data['password'] == password:
response = redirect(re.sub(
r'/login$',
'/admin',
get_current_url(env, strip_querystring=True)
))
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))
@ -1118,24 +858,20 @@ class API(object):
response.headers.add("X-Set-Cookie", cookie("isso-admin-session"))
return response
else:
isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
return render_template('login.html', isso_host_script=isso_host_script)
return render_template('login.html')
def admin(self, env, req):
isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
if not self.isso.conf.getboolean("admin", "enabled"):
return render_template('disabled.html', isso_host_script=isso_host_script)
try:
data = self.isso.unsign(req.cookies.get('admin-session', ''),
max_age=60 * 60 * 24)
except BadSignature:
return render_template('login.html', isso_host_script=isso_host_script)
return render_template('login.html')
if not data or not data['logged']:
return render_template('login.html', isso_host_script=isso_host_script)
return render_template('login.html')
page_size = 100
page = int(req.args.get('page', 0))
order_by = req.args.get('order_by', 'created')
asc = int(req.args.get('asc', 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,
@ -1151,91 +887,4 @@ class API(object):
page=int(page), mode=int(mode),
conf=self.conf, max_page=max_page,
counts=comment_mode_count,
order_by=order_by, asc=asc,
isso_host_script=isso_host_script)
"""
@api {get} /latest latest
@apiGroup Comment
@apiDescription
Get the latest comments from the system, no matter which thread
@apiParam {number} limit
The quantity of last comments to retrieve
@apiExample {curl} Get the latest 5 comments
curl 'https://comments.example.com/latest?limit=5'
@apiUse commentResponse
@apiSuccessExample Example result:
[
{
"website": null,
"uri": "/some",
"author": null,
"parent": null,
"created": 1464912312.123416,
"text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"id": 3,
"likes": 1
},
{
"website": null,
"uri": "/other",
"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": 0
}
]
"""
def latest(self, environ, request):
# if the feature is not allowed, don't present the endpoint
if not self.conf.getboolean("latest-enabled"):
return NotFound()
# get and check the limit
bad_limit_msg = "Query parameter 'limit' is mandatory (integer, >0)"
try:
limit = int(request.args['limit'])
except (KeyError, ValueError):
return BadRequest(bad_limit_msg)
if limit <= 0:
return BadRequest(bad_limit_msg)
# retrieve the latest N comments from the DB
all_comments_gen = self.comments.fetchall(limit=None, order_by='created', mode='1')
comments = collections.deque(all_comments_gen, maxlen=limit)
# prepare a special set of fields (except text which is rendered specifically)
fields = {
'author',
'created',
'dislikes',
'id',
'likes',
'mode',
'modified',
'parent',
'text',
'uri',
'website',
}
# process the retrieved comments and build results
result = []
for comment in comments:
processed = {key: comment[key] for key in fields}
processed['text'] = self.isso.render(comment['text'])
result.append(processed)
return JSON(result, 200)
order_by=order_by, asc=asc)

@ -30,7 +30,7 @@ def host(environ): # pragma: no cover
of http://www.python.org/dev/peps/pep-0333/#url-reconstruction
"""
url = environ['wsgi.url_scheme'] + '://'
url = environ['wsgi.url_scheme']+'://'
if environ.get('HTTP_HOST'):
url += environ['HTTP_HOST']
@ -84,8 +84,6 @@ def origin(hosts):
hosts = [urlsplit(h) for h in hosts]
def func(environ):
if 'ISSO_CORS_ORIGIN' in environ:
return environ['ISSO_CORS_ORIGIN']
if not hosts:
return "http://invalid.local"
@ -146,7 +144,7 @@ class CORSMiddleware(object):
if self.exposed:
headers.add("Access-Control-Expose-Headers",
", ".join(self.exposed))
return start_response(status, headers.to_wsgi_list(), exc_info)
return start_response(status, headers.to_list(), exc_info)
if environ.get("REQUEST_METHOD") == "OPTIONS":
add_cors_headers("200 Ok", [("Content-Type", "text/plain")])
@ -200,11 +198,7 @@ class SocketHTTPServer(HTTPServer, ThreadingMixIn):
multiprocess = False
allow_reuse_address = 1
try:
address_family = socket.AF_UNIX
except AttributeError:
address_family = socket.AF_INET
address_family = socket.AF_UNIX
request_queue_size = 128

@ -3,7 +3,3 @@ with-doctest=1
[bdist_wheel]
universal=1
[flake8]
ignore = E501, E402
exclude = docs/conf.py,.tox,.eggs

@ -5,17 +5,17 @@ import sys
from setuptools import setup, find_packages
requires = ['itsdangerous', 'Jinja2', 'misaka>=2.0,<3.0', 'html5lib',
'werkzeug>=1.0', 'bleach', 'flask-caching']
requires = ['html5lib==0.9999999', 'itsdangerous', 'Jinja2',
'misaka>=1.0,<2.0', 'werkzeug>=0.9']
if sys.version_info < (3, ):
raise SystemExit("Python 2 is not supported.")
if sys.version_info < (2, 7):
raise SystemExit("Python 2 versions < 2.7 are not supported.")
elif (3, 0) <= sys.version_info < (3, 4):
raise SystemExit("Python 3 versions < 3.4 are not supported.")
setup(
name='isso',
version='0.12.3dev0',
version='0.10.7.dev0',
author='Martin Zimmermann',
author_email='info@posativ.org',
packages=find_packages(),
@ -30,13 +30,15 @@ setup(
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6"
"Programming Language :: Python :: 3.7"
"Programming Language :: Python :: 3.8"
],
install_requires=requires,
setup_requires=["cffi>=1.3.0"],
extras_require={
':python_version=="2.7"': ['ipaddr>=2.1', 'configparser']
},
entry_points={
'console_scripts':
['isso = isso:main'],

@ -9,12 +9,8 @@ dbpath = /var/isso/comments.db
host = http://isso-dev.local/
max-age = 15m
notify = stdout
reply-notifications = false
log-file = /var/log/isso.log
[admin]
enabled = true
password = strong_default_password_for_isso_admin
admin_password = strong_default_password_for_isso_admin
[moderation]
enabled = false
@ -28,7 +24,6 @@ enabled = false
[markup]
options = strikethrough, autolink, fenced_code, no_intra_emphasis
flags =
allowed-elements =
allowed-attributes =

@ -43,31 +43,12 @@ max-age = 15m
# moderated) and deletion links.
notify = stdout
# Allow users to request E-mail notifications for replies to their post.
# WARNING: It is highly recommended to also turn on moderation when enabling
# this setting, as Isso can otherwise be easily exploited for sending spam.
reply-notifications=false
# Log console messages to file instead of standard output.
log-file =
# adds property "gravatar_image" to json response when true
# will automatically build md5 hash by email and use "gravatar_url" to build
# the url to the gravatar image
gravatar = false
# default url for gravatar. {} is where the hash will be placed
gravatar-url = https://www.gravatar.com/avatar/{}?d=identicon
# enable the "/latest" endpoint, that serves comment for multiple posts (not
# needing to previously know the posts URIs)
latest-enabled = false
[admin]
enabled = false
# Admin access password
password = please_choose_a_strong_password
admin_password = please_choose_a_strong_password
[moderation]
# enable comment moderation queue. This option only affects new comments.
@ -75,16 +56,6 @@ password = please_choose_a_strong_password
# them.
enabled = false
# with moderation enabled, automatically approve new comments by an
# author if they've had comments approved within the last 6 months
# Note: No verification is done on the email addresses entered by commenters.
# This means that if someone is able to guess correctly the email address used
# by a previously approved author, they will be able to have their new comment
# auto-approved. For this reason, we recommend that you also activate SMTP
# notification if you activate this option, so that you will see
# auto-approved comments as they get posted.
approve-if-email-previously-approved = false
# remove unprocessed comments in moderation queue after given time.
purge-after = 30d
@ -99,11 +70,6 @@ purge-after = 30d
# for details). Does not apply for uWSGI.
listen = http://localhost:8080
# public URL that Isso is accessed from by end users. Should always be a
# http:// or https:// absolute address. If left blank, automatic detection is
# attempted.
public-endpoint =
# reload application, when the source code has changed. Useful for development.
# Only works with the internal webserver.
reload = off
@ -112,13 +78,6 @@ reload = off
# in production.
profile = off
# an optional list of reverse proxies IPs behind which you have deployed
# your Isso web service (e.g. `127.0.0.1`).
# This allow for proper remote address resolution based on a
# `X-Forwarded-For` HTTP header, which is important for the mechanism
# forbiding several comment votes coming from the same subnet.
trusted-proxies =
[smtp]
# Isso can notify you on new comments via SMTP. In the email notification, you
@ -192,11 +151,6 @@ require-email = false
# there, separated by comma.
options = strikethrough, autolink, fenced_code, no_intra_emphasis
# Misaka-specific HTML rendering flags, all html rendering flags can be used
# here, separated by comma, either by their name or as HTML_<flag>.
# Per Misaka's defaults, no flags are set.
flags =
# Additional HTML tags to allow in the generated output, comma-separated. By
# default, only a, blockquote, br, code, del, em, h1, h2, h3, h4, h5, h6, hr,
# ins, li, ol, p, pre, strong, table, tbody, td, th, thead and ul are allowed.
@ -226,15 +180,3 @@ salt = Eech7co8Ohloopo9Ol6baimi
# strengthening. Arguments have to be in that order, but can be reduced to
# pbkdf2:4096 for example to override the iterations only.
algorithm = pbkdf2
[rss]
# Provide an Atom feed for each comment thread for users to subscribe to.
# The base URL of pages is needed to build the Atom feed. By appending
# the URI, we should get the complete URL to use to access the page
# with the comments. When empty, Atom feeds are disabled.
base =
# Limit the number of elements to return for each thread.
limit = 100

@ -1,5 +1,5 @@
[tox]
envlist = py35,py36,py37,py38
envlist = py27,py34,py35,py36
[testenv]
deps =
@ -15,8 +15,7 @@ deps =
[testenv:debian]
deps=
bleach
html5lib
html5lib==0.95
ipaddr==2.1.10
itsdangerous==0.22
misaka==1.0.2

Loading…
Cancel
Save