Compare commits

..

35 Commits

Author SHA1 Message Date
Martin Zimmermann 1f051bfabc http://javascript.about.com/od/problemsolving/a/modulobug.htm
10 years ago
Martin Zimmermann 5da9f4a410 fix base64 padding
10 years ago
Martin Zimmermann ea5aa0e80f use new signer and strip trailing base64 padding
10 years ago
Martin Zimmermann 090c01bf8a disable zlib compression in signer
10 years ago
Nicolas Le Manchet d41ab31b3d Add French translation for auth
10 years ago
Nicolas Le Manchet 8550859366 Use auth cookie in the client
10 years ago
Martin Zimmermann f42e70c768 max_age is optional now
10 years ago
Martin Zimmermann 8d71ad6c1c add dummy option for auth
10 years ago
Martin Zimmermann 701ea7058c add itsdangerous' URLSafeTimedSerializer parser
10 years ago
Martin Zimmermann 2001ed423b limit variable scope
10 years ago
Nicolas Le Manchet bf5fc622ba Remove cookie name from configuration
10 years ago
Nicolas Le Manchet b2a34caa66 Fix authentication for edit
10 years ago
Nicolas Le Manchet 7bf46d9b53 Add auth decorator to delete, edit, like/dislike
10 years ago
Nicolas Le Manchet 33741b9d2d Create the auth decorator
10 years ago
Nicolas Le Manchet 9d63498def Rename sign in config parameter
10 years ago
Nicolas Le Manchet 2347b41647 Check auth to hide the form fields
10 years ago
Nicolas Le Manchet 7174c6a686 Update .gitignore
10 years ago
Nicolas Le Manchet 43d8ae702d Update config sample to work with SQLAlchemy
10 years ago
Martin Zimmermann 9aa1e9544d add SQLAlchemy to requirements
10 years ago
Martin Zimmermann 131951c976 python3 compatibility
10 years ago
Martin Zimmermann 7a3251ddfc refactor and migration to SQLAlchemy
10 years ago
Martin Zimmermann 7886c20aef Merge branch 'master' into dev
10 years ago
Martin Zimmermann d472262fee add SQLite3-based queue
10 years ago
Martin Zimmermann 80cbf2676f drop Python 2.6
10 years ago
Martin Zimmermann 4f152d03ac remove uWSGI/Process/Threading mixin
10 years ago
Martin Zimmermann 10960ecf1e add in-memory queue, worker and task skeleton
10 years ago
Martin Zimmermann 1e542e612a move total_seconds to utilities
10 years ago
Martin Zimmermann a551271743 cache processed HTML
10 years ago
Martin Zimmermann 88689c789a connection pooling and new cache implementations
10 years ago
Martin Zimmermann bbd9e1b523 add type checking utils
10 years ago
Martin Zimmermann b2bc582f92 remove tests with too many votes
10 years ago
Martin Zimmermann 5f71b735e5 include default.ini in package
10 years ago
Martin Zimmermann bffcc3af6f include demo/index.html
10 years ago
Martin Zimmermann 1a4e760fe0 remove configuration dependency in Markup
10 years ago
Martin Zimmermann 65caa7ad99 remove configuration dependency from hash
10 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]

98
.gitignore vendored

@ -3,12 +3,11 @@
# 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
.sass-cache
.vagrant
/bin
/include
@ -26,96 +25,5 @@
/docs/_build
/docs/_static/css/site.css
/pip-selfcheck.json
# github/gitignore
Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
.venv/
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
/build
/dist

@ -1,22 +1,15 @@
language: python
matrix:
include:
- 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
python: 2.7
env:
- TOX_ENV=py27
- TOX_ENV=py33
- TOX_ENV=py34
- TOX_ENV=wheezy
install:
- pip install -U pip
- pip install flake8 tox
- pip install tox
- sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm
script:
- tox -e $TOX_ENV
- make flakes
notifications:
irc:
channels:

@ -1,255 +1,16 @@
Changelog for Isso
==================
0.12.3 (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
- 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
0.10.6 (2016-09-22)
-------------------
- fix missing configuration field
0.10.5 (2016-09-20)
-------------------
- add support for different vote levels, #260
List of vote levels used to customize comment appearance based on score.
Provide a comma-separated values (eg. `"0,5,10,25,100"`) or a JSON array (eg.
`"[-5,5,15]"`).
For example, the value `"-5,5"` will cause each `isso-comment` to be given
one of these 3 classes:
- `isso-vote-level-0` for scores lower than `-5`
- `isso-vote-level-1` for scores between `-5` and `4`
- `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).
- add new post preview API endpoint, #254
- add an option for mandatory author, #257
- clients can now use `data-title` to get the HTML title for a new page, #252
- add finish translation and other minor bugfixes
0.10.4 (2016-04-12)
-------------------
- fix wrapper attribute when using data-isso-require-mail="true", #238
- fix reponse for OPTIONS response on Python 3, #242
0.10.3 (2016-02-24)
-------------------
- follow redirects, #193
0.10.2 (2016-02-21)
-------------------
- fix getAttribute return value
0.10.1 (2016-02-06)
-------------------
- fix empty author, email and website values when writing a comment
0.10 (2016-02-06)
0.10 (unreleased)
-----------------
- add new configuration section for hash handling.
[hash]
salt = Eech7co8Ohloopo9Ol6baimi
algorithm = pbkdf2
- Nothing changed yet.
You can customize the salt, choose different hash functions and tweak the
parameters for PBKDF2.
- Python 3.4+ validate TLS connections against the system's CA. Previously no
validation was in place, see PEP-446__ for details.
- add `fenced_code` and `no_intra_emphasis` to default configuration.
Fenced code allows to write code without indentation using `~~~` delimiters
(optionally with language identifier).
Intra emphasis would compile `foo_bar_baz` to foo<em>bar</em>baz. This
behavior is very confusing for users not knowing the Markdown spec in detail.
- new configuration to require an email when submitting comments, #199. Set
[guard]
require-email = true
and use `data-isso-require-email="true"` to enable this feature. Disabled by
default.
- new Bulgarian translation by sahwar, new Swedish translation by Gustav
Näslund #143, new Vietnamese translation by Đinh Xuân Sâm, new Croatian
translation by streger, new Czech translation by Jan Chren
- fix SMTP setup without credentials, #174
- version pin Misaka to 1.x, html5lib to 0.9999999
.. __: https://www.python.org/dev/peps/pep-0466/
0.9.10 (2015-04-11)
-------------------
- fix regression in SMTP authentication, #174
0.9.9 (2015-03-04)
------------------
- several Python 3.x related bugfixes
- don't lose comment form if the server rejected the POST request, #144
- add localStorage fallback if QUOTA_EXCEEDED_ERR is thrown (e.g. Safari
private browsing)
- add '--empty-id' flag to Disqus import, because Disqus' export sucks
- (re)gain compatibility with Werkzeug 0.8 and really old html5lib versions
available in Debian Squeeze, #170 & #168
- add User-Agent when Isso requests the URL, an alternate way to #151 (add
'X-Isso' when requesting).
0.9.8 (2014-10-08)
0.9.5 (unreleased)
------------------
- add compatibility with configparser==3.5.0b1, #128
0.9.7 (2014-09-25)
------------------
- fix SMTP authentication using CRAM-MD5 (incorrect usage of
`smtplib`), #126
0.9.6 (2014-08-18)
------------------
- remember name, email and website in localStorage, #119
- add option to hide voting feature, #115
data-isso-vote="true|false"
- remove email field from JSON responses
This is a quite serious issue. For the identicon, an expensive hash is used
to avoid the leakage of personal information like a real email address. A
`git blame` reveals, the email has been unintenionally exposed since the very
first release of Isso :-/
The testsuite now contains a dedicated test to prevent this error in the
future.
0.9.5 (2014-08-10)
------------------
- prevent no-break space (&nbsp;) insertion to enable manual line breaks using
two trailing spaces (as per Markdown convention), #112
- limit request size to 256 kb, #107
Previously unlimited or limited by proxy server). 256 kb is a rough
approximation of the next database schema with comments limited to 65535
characters and additional fields.
- add support for logging to file, #103
[general]
log-file =
- show timestamp when hovering <time>, #104
- fix a regression when editing comments with multiple paragraphs introduced
in 0.9.3 which would HTML escape manually inserted linebreaks.
- Nothing changed yet.
0.9.4 (2014-07-09)

@ -4,11 +4,6 @@
* Martin Zimmermann <info@posativ.org>
## Co-Maintainers
* Benoît Latinier @blatinier <benoit@latinier.fr>
* Jelmer Vernooij <jelmer@jelmer.uk>
## Contributors
@ -46,76 +41,6 @@ In chronological order:
* Baptiste Darthenay
* Esperanto translation
* Matías Ducasa <https://github.com/matiasducasa>
* Spanish translation
* Daniel Gräber <https://github.com/albohlabs>
* Added ansible for provisioning
* Nick Hu <https://github.com/NickHu>
* Added configuration to require email addresses (no validation)
* Fix Vagrantfile
* Benoît Latinier @blatinier <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 +0,0 @@
# First, compile JS stuff
FROM node:dubnium-buster
WORKDIR /src/
COPY . .
RUN npm install -g requirejs uglify-js jade bower \
&& make init js
# 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/*
# Third, create final repository
FROM python:3.8-slim-buster
WORKDIR /isso/
COPY --from=1 /isso .
# 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

@ -1,19 +1,12 @@
include man/man1/isso.1
include man/man5/isso.conf.5
include isso/defaults.ini
include share/isso.conf
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
include isso/img/isso.svg
include isso/demo/index.html

@ -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
@ -9,7 +7,7 @@ ISSO_JS_DST := isso/js/embed.min.js isso/js/embed.dev.js \
ISSO_CSS := isso/css/isso.css
ISSO_PY_SRC := $(shell git ls-files | grep -E "^isso/.+.py$$")
ISSO_PY_SRC := $(shell git ls-files | grep .py)
DOCS_RST_SRC := $(shell find docs/ -type f -name '*.rst') \
$(wildcard docs/_isso/*) \
@ -27,34 +25,26 @@ DOCS_MAN_DST := man/man1/isso.1 man/man5/isso.conf.5
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
(cd isso/js; bower install almond requirejs requirejs-text jade)
isso/js/%.min.js: $(ISSO_JS_SRC) $(ISSO_CSS)
$(RJS) -o isso/js/build.$*.js out=$@
r.js -o isso/js/build.$*.js out=$@
isso/js/%.dev.js: $(ISSO_JS_SRC) $(ISSO_CSS)
$(RJS) -o isso/js/build.$*.js optimize="none" out=$@
r.js -o isso/js/build.$*.js optimize="none" out=$@
js: $(ISSO_JS_DST)
man: $(DOCS_RST_SRC)
sphinx-build -b man docs/ man/
mkdir -p man/man1/ man/man5
mv man/isso.1 man/man1/isso.1
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 +55,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
============================================

65
Vagrantfile vendored

@ -1,65 +0,0 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
# This is the Vagrant config file for setting up an environment for Isso development.
# It requires:
#
# * Vagrant (https://vagrantup.com)
# * A VM engine, like VirtualBox (https://virtualbox.org)
# * The Vagrant-Hostmanager plugin (https://github.com/smdahlen/vagrant-hostmanager)
# * Ansible (https://www.ansible.com)
#
# With them installed, cd into this directory and do 'vagrant up'. It's possible Vagrant will
# ask for your root password so it can update your /etc/hosts file. Once it's happily churning out
# console output, go get a coffee :)
#
# The resulting VM should be accessible at http://isso-dev.local/ so you can try the demo page out.
# Edit files in your checkout as usual. If you need to look at log files and stuff, 'vagrant ssh'
# to get into the VM. Useful info about it:
#
# * Running Ubuntu 14.04
# * Isso is running on uWSGI
# * Actual webserver is Nginx to talk to uWSGI over a unix socket
# * uWSGI log file is /var/log/uwsgi/apps/isso.log
# * Isso DB file is /var/isso/comments.db
# * Isso log file is /var/log/isso.log
#
# When the VM is getting rebooted vagrant mounts the shared folder after uWSGI is getting startet. To fix this issue for
# the moment you need to 'vagrant ssh' into the VM and execute 'sudo service uwsgi restart'.
#
# For debugging with _pudb_ stop uWSGI service and start it manually
# 'sudo uwsgi --ini /etc/uwsgi/apps-available/isso.ini'.
#
# Enjoy!
Vagrant.configure(2) do |config|
# The most common configuration options are documented and commented below.
# For a complete reference, please see the online documentation at
# https://docs.vagrantup.com.
config.vm.box = "ubuntu/trusty32"
config.vm.hostname = 'isso-dev.local'
config.vm.network "private_network", type: "dhcp"
config.hostmanager.enabled = true
config.hostmanager.manage_host = true
config.hostmanager.ignore_private_ip = false
config.hostmanager.include_offline = true
config.hostmanager.ip_resolver = proc do |machine|
result = ""
machine.communicate.execute("ifconfig eth1") do |type, data|
result << data if type == :stdout
end
(ip = /inet addr:(\d+\.\d+\.\d+\.\d)/.match(result)) && ip[1]
end
config.vm.provision "ansible" do |ansible|
ansible.playbook = "ansible/site.yml"
ansible.limit = "all"
ansible.verbose = "v"
end
config.vm.post_up_message = "Browse to http://isso-dev.local/demo/index.html to start."
end

@ -1,39 +0,0 @@
user root;
worker_processes 4;
worker_rlimit_nofile 8192;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 2014;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
log_format timed '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'$request_time $upstream_response_time $upstream_addr '
' $upstream_status $upstream_cache_status $pipe';
access_log /var/log/nginx/access.log timed;
sendfile on;
tcp_nopush on;
keepalive_timeout 30;
gzip on;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}

@ -1,13 +0,0 @@
server {
client_max_body_size 20M;
listen 80 default_server;
server_name isso-dev.local;
root /vagrant/isso/demo;
location / {
# uwsgi_pass unix:///run/uwsgi/app/isso/socket;
uwsgi_pass 127.0.0.1:8080;
include uwsgi_params;
}
}

@ -1,24 +0,0 @@
[uwsgi]
plugins = python
chdir = /vagrant
uid = root
gid = root
socket = :8080
master = true
processes = 4
cache2 = name=hash,items=10240,blocksize=32
spooler = /var/isso/spool
module = isso.run
env = ISSO_SETTINGS=/vagrant/share/isso-dev.conf
env = PYTHON_EGG_CACHE=/tmp
# uncomment for debugging
# daemonize = /var/log/uwsgi/uwsgi.log
py-autoreload = 1
# prevent uWSGI from remapping stdin to /dev/null
honour-stdin = true

@ -1,85 +0,0 @@
---
- name: Provision development server
hosts: all
sudo: true
tasks:
- name: Apt | Add nodesource keys
apt_key: url=https://deb.nodesource.com/gpgkey/nodesource.gpg.key state=present
- name: Apt | Add nodesource sources list deb
apt_repository: repo='deb https://deb.nodesource.com/node {{ ansible_distribution_release }} main' state=present
- name: Apt | Add nodesource sources list deb src
apt_repository: repo='deb-src https://deb.nodesource.com/node {{ ansible_distribution_release }} main' state=present
- name: Apt | Install packages
apt: pkg={{ item }} state=latest update_cache=true
with_items:
- build-essential
- curl
- htop
- vim
- git
- python-dev
- python-software-properties
- python-setuptools
- python-pip
- nginx
- uwsgi
- uwsgi-plugin-python
- supervisor
- sqlite3
- nodejs
- libffi-dev
- name: NPM | Install packages
npm: name={{ item }} global=yes
with_items:
- bower
- requirejs
- uglify-js
- jade
- name: Python | Install egg
shell: cd /vagrant; python setup.py develop
- name: Make
shell: cd /vagrant; {{ item }}
with_items:
- make init
- make js
- name: Spool | Create directory
file: path=/var/isso/spool state=directory mode=0777
- name: uwsgi | Deploy configuration
copy: src=files/uwsgi.ini dest=/etc/uwsgi/apps-available/isso.ini
- name: uwsgi | Enable app
file: src=/etc/uwsgi/apps-available/isso.ini dest=/etc/uwsgi/apps-enabled/isso.ini state=link
- name: uwsgi | Restart service daemon
service: name=uwsgi state=restarted enabled=yes
- name: uwsgi | Chmod logfile
file: path=/var/log/uwsgi/uwsgi.log state=touch mode="a+r"
- name: nginx | Deploy nginx.conf
copy: src=files/nginx.conf dest=/etc/nginx/nginx.conf
- name: nginx | Delete default vhost
action: file path=/etc/nginx/sites-enabled/default state=absent
- name: nginx | Deploy vhost config
copy: src=files/nginx.vhost.conf dest=/etc/nginx/sites-available/isso.conf
- name: nginx | Enable vhost
file: src=/etc/nginx/sites-available/isso.conf dest=/etc/nginx/sites-enabled/000-isso state=link
- name: nginx | Chmod logfile
file: path=/var/log/nginx mode="a+rx" state=directory recurse=true
- name: nginx | Restart service daemon
service: name=nginx state=restarted enabled=yes

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

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

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

@ -9,3 +9,4 @@ class IssoTranslator(HTMLTranslator):
if self.section_level == 1:
raise nodes.SkipNode
HTMLTranslator.visit_title(self, node)

@ -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
@ -64,7 +64,7 @@ master_doc = 'docs/index'
# General information about the project.
project = u'Isso'
copyright = u'2016, Martin Zimmermann'
copyright = u'2014, Martin Zimmermann'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@ -171,7 +171,7 @@ html_static_path = ['_static']
# Additional templates that should be rendered to pages, maps page names to
# template names.
html_additional_pages = {"index": "index.html"}
html_additional_pages = {"index": "docs/index.html"}
# If false, no module index is generated.
html_domain_indices = False
@ -200,22 +200,22 @@ htmlhelp_basename = 'Issodoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'Isso.tex', u'Isso Documentation',
u'Martin Zimmermann', 'manual'),
('index', 'Isso.tex', u'Isso Documentation',
u'Martin Zimmermann', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@ -260,9 +260,9 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Isso', u'Isso Documentation',
u'Martin Zimmermann', 'Isso', 'a commenting server similar to Disqus',
'Miscellaneous'),
('index', 'Isso', u'Isso Documentation',
u'Martin Zimmermann', 'Isso', 'a commenting server similar to Disqus',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.

@ -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,39 +7,23 @@ 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"
data-isso-avatar="true"
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-avatar-bg="#f0f0f0"
data-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
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
---------
@ -65,33 +49,14 @@ Defaults to `true`.
data-isso-lang
--------------
Override useragent's preferred language. Isso has been translated in over 12
languages. The language is configured by its `ISO 639-1
<https://en.wikipedia.org/wiki/ISO_639-1>`_ (two letter) code.
You find a list of all supported languages on `GitHub
<https://github.com/posativ/isso/tree/master/isso/js/app/i18n>`_.
Override useragent's preferred language. Currently available: german (de),
english (en), french (fr), italian (it), esperanto (eo) and russian (ru).
data-isso-reply-to-self
-----------------------
Set to `true` when spam guard is configured with `reply-to-self = true`.
data-isso-require-author
------------------------
Set to `true` when spam guard is configured with `require-author = true`.
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,36 +88,14 @@ 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
--------------
Enable or disable voting feature on the client side.
data-isso-id
------------
data-isso-vote-levels
---------------------
Broken do not use. https://github.com/posativ/isso/issues/27
List of vote levels used to customize comment appearance based on score.
Provide a comma-separated values (eg. `"0,5,10,25,100"`) or a JSON array (eg. `"[-5,5,15]"`).
Set a custom thread id, defaults to current URI. If you a comment counter, add
this attribute to the link tag, too.
For example, the value `"-5,5"` will cause each `isso-comment` to be given one of these 3 classes:
- `isso-vote-level-0` for scores lower than `-5`
- `isso-vote-level-1` for scores between `-5` and `4`
- `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
--------------
.. code-block:: html
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.
<section data-isso-id="test.abc" id="isso-thread"></section>

@ -44,7 +44,6 @@ session key and hostname. Here are the default values for this section:
host =
max-age = 15m
notify = stdout
log-file =
dbpath
file location to the SQLite3 database, highly recommended to change this
@ -55,7 +54,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
@ -81,40 +80,12 @@ notify
Available backends:
stdout
Log to standard output. Default, if none selected. Note, this
functionality is broken since a few releases.
Log to standard output. Default, if none selected.
smtp
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 +99,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 +139,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 +147,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
@ -262,15 +212,13 @@ for IPv4, ``/48`` for IPv6).
ratelimit = 2
direct-reply = 3
reply-to-self = false
require-author = false
require-email = false
enabled
enable guard, recommended in production. Not useful for debugging
purposes.
ratelimit
limit to N new comments per minute.
limit to N new comments per minute. Use -1 to disable rate limit.
direct-reply
how many comments directly to the thread (prevent a simple
@ -281,19 +229,7 @@ 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
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.
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 +241,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 +289,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 +301,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`

@ -21,36 +21,5 @@ Now, either include `count.min.js` if you want to show only the comment count
(e.g. on an index page) or `embed.min.js` for the full comment client (see
:doc:`../quickstart`); do not mix both.
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.
You can have as many comments counters as you want in a page but be aware that it
implies one `GET` request per comment anchor.

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

@ -23,11 +23,12 @@ Isso:
"mode": 1,
"hash": "4505c1eeda98",
"author": null,
"website": null,
"email": null,
"website": null
"created": 1387321261.572392,
"modified": null,
"likes": 3,
"dislikes": 0
"dislikes": 0,
}
id :
@ -70,7 +71,7 @@ modified :
List comments
-------------
List all publicly visible comments for thread `uri`:
List all publicely visible comments for thread `uri`:
.. code-block:: text
@ -83,19 +84,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 +186,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
@ -13,11 +13,10 @@ and web-scale than the built-in web server.
* mod_wsgi_, Apache interface to WSGI
* mod_fastcgi_, Apache interface to FastCGI
* uberspace.de, `try this guide (in german) <http://blog.posativ.org/2014/isso-und-uberspace-de/>`_
* Openshift, Isso has a one click installer
`gevent <http://www.gevent.org/>`__
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
`gevent <http://gunicorn.org/>`__
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Probably the easiest deployment method. Install with PIP (requires libevent):
@ -98,108 +97,34 @@ 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`:
.. code-block:: python
from __future__ import unicode_literals
import os
from isso import make_app
from isso import dist, config
application = make_app(
config.load(
os.path.join(dist.location, dist.project_name, "defaults.ini"),
"/path/to/isso.cfg"),
multiprocessing=True)
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
.. note:: This information may be incorrect, if you have more knowledge on how
to deploy Python via `mod_wsgi`, consider extending/correcting this section.
import site
site.addsitedir("/path/to/isso_virtualenv")
For more information, see `Flask: Configuring Apache
<http://flask.pocoo.org/docs/deploying/mod_wsgi/#configuring-apache>`_.
import os
.. code-block:: apache
from isso import make_app
from isso import dist, config
<VirtualHost *>
ServerName example.org
application = make_app(
config.load(
os.path.join(dist.location, dist.project_name, "defaults.ini"),
"/path/to/isso.cfg",
multiprocessing=True)
WSGIDaemonProcess isso user=www-data group=www-data threads=5
WSGIScriptAlias / /var/www/isso.wsgi
</VirtualHost>
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
the virtualenv have priority over system modules, the following script does the trick:
Next, copy'n'paste to `/var/www/isso.wsgi`:
.. code-block:: python
from __future__ import unicode_literals
import os
import site
import sys
# Remember original 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
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
`mod_wsgi documentation <https://code.google.com/p/modwsgi/wiki/VirtualEnvironments>`_.
The Apache configuration will then be similar to the following:
.. code-block:: apache
<VirtualHost *>
ServerName example.org
WSGIDaemonProcess isso user=www-data group=www-data threads=5
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
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,51 +148,19 @@ 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>`__
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
With `Isso Openshift Deployment Kit`_, Isso can be installed on Open
Shift with just one click. Make sure you already have installed ``rhc``
(`instructions`_) and completed the setup.
1. Run the following, you will get an Open Shift instance installed with
Isso:
::
rhc create-app appname python-2.7 --from-code https://github.com/avinassh/isso-openshift.git
2. Above step also clones Git repository of your Open Shift instance, in
current directory. Make changes to the configuration file and push
back to Openshift, it will be redeployed with new settings.
3. Visit ``http://<yourappname>-<openshift-namespace>.com/info`` to
verify Isso is deployed properly and is working.
.. _Isso Openshift Deployment Kit: https://github.com/avinassh/isso-openshift
.. _instructions: https://developers.openshift.com/en/managing-client-tools.html

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

@ -1,17 +1,15 @@
Installation
============
Isso is a web application written in Python. If pip and virtualenv mean anything
to you, continue with :ref:`install-from-pypi`. If you are running
Debian/Ubuntu, Gentoo, Archlinux or Fedora, you can use
:ref:`prebuilt-package`. If not, read the next section carefully.
Isso is a web application written in Python. If pip and virtualenv mean
anything to you, continue with :ref:`install-from-pypi`. If you are running
Debian/Ubuntu or Gentoo, you can use :ref:`prebuilt-package`. If not, read the
next section carefully.
.. contents::
:local:
:depth: 1
.. _install-interludium:
Interludium: Python is not PHP
------------------------------
@ -22,12 +20,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,18 +37,18 @@ 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):
.. code-block:: sh
~> virtualenv /opt/isso
~> source /opt/isso/bin/activate
~> virtualenv /path/to/isso
~> source /path/to/isso/bin/activate
After calling `source`, you can now install packages from PyPi locally into this
virtual environment. If you don't like Isso anymore, you just `rm -rf` the
@ -58,11 +56,6 @@ folder. Inside this virtual environment, you may also execute the example
commands from above to upgrade your Python Package Manager (although it barely
makes sense), it is completely independent from your global system.
To use Isso installed in a virtual environment outside of the virtual
environment, you just need to add */opt/isso/bin* to your :envvar:`PATH` or
execute */opt/isso/bin/isso* directly. It will launch Isso from within the
virtual environment.
With a virtualenv active, you may now continue to :ref:`install-from-pypi`!
Of course you may not need a virtualenv when you are running dedicated virtual
machines or a shared host (e.g. Uberspace.de).
@ -79,7 +72,7 @@ Install from PyPi
Requirements
^^^^^^^^^^^^
- Python 2.7 or 3.4+ (+ devel headers)
- Python 2.7, 3.3 or 3.4+ (+ devel headers)
- SQLite 3.3.8 or later
- a working C compiler
@ -117,7 +110,7 @@ For easier execution, you can symlink the executable to a location in your
.. code-block:: sh
~> ln -s /opt/isso/bin/isso /usr/local/bin/isso
~> ln -s /path/to/isso-venv/bin/isso /usr/local/bin/isso
Upgrade
^^^^^^^
@ -126,7 +119,7 @@ To upgrade Isso, activate your virtual environment again, and run
.. code-block:: sh
~> source /opt/isso/bin/activate # optional
~> source /path/to/isso/bin/activate # optional
~> pip install --upgrade isso
.. _prebuilt-package:
@ -134,7 +127,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.
@ -142,29 +139,13 @@ Prebuilt Packages
* Arch Linux: https://aur.archlinux.org/packages/isso/
install with `yaourt isso`.
* 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.
Install from Source
-------------------
If you want to hack on Isso or track down issues, there's an alternate
way to set up Isso. It requires a lot more dependencies and effort:
- Python 2.7 or 3.4+ (+ devel headers)
- Python 2.6, 2.7 or 3.3+ (+ devel headers)
- Virtualenv
- SQLite 3.3.8 or later
- a working C compiler
@ -197,7 +178,7 @@ Install JavaScript modules:
~> make init
Integration without optimization:
Integration without previous optimization:
.. code-block:: html
@ -208,7 +189,7 @@ Optimization:
.. code-block:: sh
~> npm install -g requirejs uglify-js jade
~> npm install -g requirejs uglifyjs jade
~> make js
.. _init-scripts:
@ -217,58 +198,10 @@ Init scripts
------------
Init scripts to run Isso as a service (check your distribution's documentation
for your init-system; e.g. Debian uses SysVinit, Fedora uses systemd) if you
for your init-system; e.g. Debian uses SysVinit, Fedora uses SystemD) if you
don't use FastCGi or uWSGI:
- systemd (Isso + Gunicorn): https://github.com/jgraichen/debian-isso/blob/master/debian/isso.service
- SysVinit (Isso + Gunicorn): https://github.com/jgraichen/debian-isso/blob/master/debian/isso.init
- SystemD: https://github.com/jgraichen/debian-isso/blob/master/debian/isso.service
- SysVinit: https://github.com/jgraichen/debian-isso/blob/master/debian/isso.init
- OpenBSD: https://gist.github.com/noqqe/7397719
- FreeBSD: https://gist.github.com/ckoepp/52f6f0262de04cee1b88ef4a441e276d
- Supervisor: https://github.com/posativ/isso/issues/47
If you're writing your own init script, you can utilize ``start-stop-daemon``
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:
.. code-block:: sh
#!/bin/sh
### BEGIN INIT INFO
# Provides: isso
# Required-Start: $local_fs $network
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Description: lightweight Disqus alternative
### END INIT INFO
EXEC=/opt/isso/bin/isso
EXEC_OPTS="-c /etc/isso.cfg run"
RUNAS=isso
PIDFILE=/var/run/isso.pid
start() {
echo 'Starting service…' >&2
start-stop-daemon --start --user "$RUNAS" --background --make-pidfile --pidfile $PIDFILE \
--exec $EXEC -- $EXEC_OPTS
}
stop() {
echo 'Stopping service…' >&2
start-stop-daemon --stop --user "$RUNAS" --pidfile $PIDFILE --exec $EXEC
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}"
esac

@ -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,12 +2,13 @@ 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:
:depth: 1
Configuration
-------------
@ -18,8 +19,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/
@ -32,7 +32,7 @@ sane defaults.
https://example.tld/
Note, that multiple, *different* websites are **not** supported in a single
configuration. To serve comments for different websites, refer to
configuration. To serve comments for diffent websites, refer to
:ref:`Multiple Sites <configure-multiple-sites>`.
The moderation is done with signed URLs sent by email or logged to stdout.
@ -45,7 +45,7 @@ enable moderation queue, add:
enabled = true
To moderate comments, either use the activation or deletion URL in the logs or
:ref:`use SMTP <configure-smtp>` to get notified of new comments, including the
:ref:`use SMTP <configure-smtp>` to get notified on new comments including the
URLs for activation and deletion:
.. code-block:: ini
@ -61,23 +61,21 @@ 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
comments. Unfortunately, Disqus does not export up- and downvotes.
To export comments from your previous WordPress installation, go to *Tools*,
export your data. It has been reported that WordPress may generate broken XML.
Try to repair the file using ``xmllint`` before you continue with the import.
export your data. WordPress WXR import is quite new and may not work for you;
please report any failures.
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 +139,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,39 +1,4 @@
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
-----------------------------------
This is usually caused by messing up the system's Python with newer packages
from PyPi (e.g. by executing `easy_install --upgrade pip` as root) and is not
related to Isso at all.
Install Isso in a virtual environment as described in
:ref:`install-interludium`. Alternatively, you can use `pip install --user`
to install Isso into the user's home.
UnicodeDecodeError: 'ascii' codec can't decode byte 0xff
--------------------------------------------------------
Likely an issue with your environment, check you set your preferred file
encoding either in :envvar:`LANG`, :envvar:`LANGUAGE`, :envvar:`LC_ALL` or
:envvar:`LC_CTYPE`:
.. code-block:: text
$ env LANG=C.UTF-8 isso [-h] [--version] ...
If none of the mentioned variables are 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).
The web console shows 404 Not Found responses
---------------------------------------------
That's fine. Isso returns "404 Not Found" to indicate "No comments".
To be written.

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

@ -35,46 +35,43 @@ import sys
if sys.argv[0].startswith("isso"):
try:
import gevent.monkey
gevent.monkey.patch_all()
import gevent.monkey; gevent.monkey.patch_all()
except ImportError:
pass
import os
import errno
import atexit
import logging
import tempfile
from os.path import dirname, join
from argparse import ArgumentParser
from functools import partial, reduce
import pkg_resources
werkzeug = pkg_resources.get_distribution("werkzeug")
from itsdangerous import URLSafeTimedSerializer
from werkzeug.routing import Map
from werkzeug.routing import Map, Rule, redirect
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])
from isso import config, db, migrate, wsgi, ext, views
from isso.core import ThreadedMixin, ProcessMixin, uWSGIMixin
try:
import uwsgi
except ImportError:
uwsgi = None
from isso import cache, config, db, migrate, ext, queue, spam, views, wsgi
from isso.wsgi import origin, urlsplit
from isso.utils import http, JSONRequest, html, hash
from isso.views import comments
from isso.utils import http, JSONRequest, html, hash, URLSafeTimedSerializer
from isso.ext.notifications import Stdout, SMTP
logging.getLogger('werkzeug').setLevel(logging.WARN)
logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
@ -82,61 +79,72 @@ 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):
self.conf = conf
self.db = db.SQLite3(conf.get('general', 'dbpath'), conf)
self.signer = URLSafeTimedSerializer(
self.db.preferences.get("session-key"))
self.markup = html.Markup(conf.section('markup'))
self.hasher = hash.new(conf.section("hash"))
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
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)
self.urls = Map()
def __init__(self, conf, cacheobj=None, dbobj=None):
views.Info(self)
comments.API(self, self.hasher)
if cacheobj is None:
cacheobj = cache.Cache(1024)
def render(self, text):
return self.markup.render(text)
if dbobj is None:
dbobj = db.Adapter("sqlite:///:memory:")
def sign(self, obj):
return self.signer.dumps(obj)
def unsign(self, obj, max_age=None):
return self.signer.loads(obj, max_age=max_age or self.conf.getint('general', 'max-age'))
self.conf = conf
self.db = dbobj
signer = URLSafeTimedSerializer(
dbobj.preferences.get("session-key"))
markup = html.Markup(
conf.getlist("markup", "options"),
conf.getlist("markup", "allowed-elements"),
conf.getlist("markup", "allowed-attributes"))
hasher = hash.new(
conf.get("hash", "algorithm"),
conf.get("hash", "salt"))
guard = spam.Guard(
dbobj,
conf.getboolean("guard", "enabled"),
conf.getint("guard", "ratelimit"),
conf.getint("guard", "direct-reply"),
conf.getboolean("guard", "reply-to-self"),
conf.getint("general", "max-age"))
urls = Map()
Isso.routes(
urls,
views.API(conf, cacheobj, dbobj, guard, hasher.uhash, markup, signer),
views.Info(conf))
self.urls = urls
@classmethod
def routes(cls, urls, api, info):
for rule in [
Rule("/demo/", endpoint=lambda *z: redirect("/demo/index.html")),
Rule("/info", endpoint=info.show)
]:
urls.add(rule)
for func, (method, rule) in [
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('POST', '/count')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('moderate',('GET', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
('moderate',('POST', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')),
]:
urls.add(Rule(rule, methods=[method], endpoint=getattr(api, func)))
def dispatch(self, request):
local.request = request
local.host = wsgi.host(request.environ)
local.origin = origin(self.conf.getiter(
"general", "host"))(request.environ)
local.origin = origin(self.conf.getiter("general", "host"))(request.environ)
adapter = self.urls.bind_to_environ(request.environ)
@ -150,8 +158,7 @@ class Isso(object):
except HTTPException as e:
return e
except Exception:
logger.exception("%s %s", request.method,
request.environ["PATH_INFO"])
logger.exception("%s %s", request.method, request.environ["PATH_INFO"])
return InternalServerError()
else:
return response
@ -164,22 +171,25 @@ class Isso(object):
return self.wsgi_app(environ, start_response)
def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
def make_app(conf):
if not any((threading, multiprocessing, uwsgi)):
raise RuntimeError("either set threading, multiprocessing or uwsgi")
dbobj = db.Adapter(conf.get("general", "dbpath"))
if threading:
class App(Isso, ThreadedMixin):
pass
elif multiprocessing:
class App(Isso, ProcessMixin):
pass
if uwsgi is not None:
cacheobj = cache.uWSGICache(timeout=3600)
else:
class App(Isso, uWSGIMixin):
pass
cacheobj = cache.SQLite3Cache(db.SQLite3("/dev/shm/isso"), threshold=2048)
jobs = queue.Jobs()
jobs.register("db-purge", dbobj, conf.getint("moderation", "purge-after"))
queueobj = queue.Queue(1024)
worker = queue.Worker(queueobj, jobs)
isso = Isso(conf, cacheobj, dbobj)
isso = App(conf)
atexit.register(worker.join, 0.25)
worker.start()
# check HTTP server connection
for host in conf.getiter("general", "host"):
@ -194,26 +204,22 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
wrapper = [local_manager.make_middleware]
if isso.conf.getboolean("server", "profile"):
if conf.getboolean("server", "profile"):
wrapper.append(partial(ProfilerMiddleware,
sort_by=("cumulative", ), restrictions=("isso/(?!lib)", 10)))
sort_by=("cumulative", ), restrictions=("isso/(?!lib)", 10)))
wrapper.append(partial(SharedDataMiddleware, exports={
'/js': join(dirname(__file__), 'js/'),
'/css': join(dirname(__file__), 'css/'),
'/img': join(dirname(__file__), 'img/'),
'/demo': join(dirname(__file__), 'demo/')
}))
wrapper.append(partial(wsgi.CORSMiddleware,
origin=origin(isso.conf.getiter("general", "host")),
allowed=("Origin", "Referer", "Content-Type"),
exposed=("X-Set-Cookie", "Date")))
origin=origin(conf.getiter("general", "host")),
allowed=("Origin", "Referer", "Content-Type"),
exposed=("X-Set-Cookie", "Date")))
wrapper.extend([wsgi.SubURI, ProxyFixCustom])
if werkzeug.version.startswith("0.8"):
wrapper.append(wsgi.LegacyWerkzeugMiddleware)
wrapper.extend([wsgi.SubURI, ProxyFix])
return reduce(lambda x, f: f(x), wrapper, isso)
@ -223,8 +229,7 @@ def main():
parser = ArgumentParser(description="a blog comment hosting service")
subparser = parser.add_subparsers(help="commands", dest="command")
parser.add_argument('--version', action='version',
version='%(prog)s ' + dist.version)
parser.add_argument('--version', action='version', version='%(prog)s ' + dist.version)
parser.add_argument("-c", dest="conf", default="/etc/isso.conf",
metavar="/etc/isso.conf", help="set configuration file")
@ -233,51 +238,39 @@ 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")
imprt.add_argument("--empty-id", dest="empty_id", action="store_true",
help="workaround for weird Disqus XML exports, #135")
choices=["disqus", "wordpress"], help="export type")
# run Isso as stand-alone server
subparser.add_parser("run", help="run server")
serve = subparser.add_parser("run", help="run server")
args = parser.parse_args()
conf = config.load(
join(dist.location, dist.project_name, "defaults.ini"), args.conf)
conf = config.load(join(dist.location, "isso", "defaults.ini"), args.conf)
if args.command == "import":
conf.set("guard", "enabled", "off")
if args.dryrun:
xxx = tempfile.NamedTemporaryFile()
dbpath = xxx.name
dbpath = ":memory:"
else:
dbpath = conf.get("general", "dbpath")
mydb = db.SQLite3(dbpath, conf)
migrate.dispatch(args.type, mydb, args.dump, args.empty_id)
mydb = db.Adapter(db.SQLite3(dbpath), conf)
migrate.dispatch(args.type, mydb, args.dump)
sys.exit(0)
if conf.get("general", "log-file"):
handler = logging.FileHandler(conf.get("general", "log-file"))
logger.addHandler(handler)
logging.getLogger("werkzeug").addHandler(handler)
logger.propagate = False
logging.getLogger("werkzeug").propagate = False
if not any(conf.getiter("general", "host")):
logger.error("No website(s) configured, Isso won't work.")
sys.exit(1)
app = make_app(conf)
if conf.get("server", "listen").startswith("http://"):
host, port, _ = urlsplit(conf.get("server", "listen"))
try:
from gevent.pywsgi import WSGIServer
WSGIServer((host, port), make_app(conf)).serve_forever()
WSGIServer((host, port), app).serve_forever()
except ImportError:
run_simple(host, port, make_app(conf), threaded=True,
run_simple(host, port, app, threaded=True,
use_reloader=conf.getboolean('server', 'reload'))
else:
sock = conf.get("server", "listen").partition("unix://")[2]
@ -286,4 +279,4 @@ def main():
except OSError as ex:
if ex.errno != errno.ENOENT:
raise
wsgi.SocketHTTPServer(sock, make_app(conf)).serve_forever()
wsgi.SocketHTTPServer(sock, app).serve_forever()

@ -0,0 +1,105 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import abc
import json
from isso.utils import types
from isso.compat import string_types
def pickle(value):
return json.dumps(value).encode("utf-8")
def unpickle(value):
return json.loads(value.decode("utf-8"))
class Base(object):
"""Base class for all cache objects.
Arbitrary values are set by namespace and key. Namespace and key must be
strings, the underlying cache implementation may use :func:`pickle` and
:func:`unpickle:` to safely un-/serialize Python primitives.
:param threshold: maximum size of the cache
:param timeout: key expiration
"""
__metaclass__ = abc.ABCMeta
# enable serialization of Python primitives
serialize = False
def __init__(self, threshold, timeout):
self.threshold = threshold
self.timeout = timeout
def get(self, ns, key, default=None):
types.require(ns, string_types)
types.require(key, string_types)
try:
value = self._get(ns.encode("utf-8"), key.encode("utf-8"))
except KeyError:
return default
else:
if self.serialize:
value = unpickle(value)
return value
@abc.abstractmethod
def _get(self, ns, key):
return
def set(self, ns, key, value):
types.require(ns, string_types)
types.require(key, string_types)
if self.serialize:
value = pickle(value)
return self._set(ns.encode("utf-8"), key.encode("utf-8"), value)
@abc.abstractmethod
def _set(self, ns, key, value):
return
def delete(self, ns, key):
types.require(ns, string_types)
types.require(key, string_types)
return self._delete(ns.encode("utf-8"), key.encode("utf-8"))
@abc.abstractmethod
def _delete(self, ns, key):
return
class Cache(Base):
"""Implements a simple in-memory cache; once the threshold is reached, all
cached elements are discarded (the timeout parameter is ignored).
"""
def __init__(self, threshold=512, timeout=-1):
super(Cache, self).__init__(threshold, timeout)
self.cache = {}
def _get(self, ns, key):
return self.cache[ns + b'-' + key]
def _set(self, ns, key, value):
if len(self.cache) > self.threshold - 1:
self.cache.clear()
self.cache[ns + b'-' + key] = value
def _delete(self, ns, key):
self.cache.pop(ns + b'-' + key, None)
from .uwsgi import uWSGICache
from .sqlite import SQLite3Cache
__all__ = ["Cache", "SQLite3Cache", "uWSGICache"]

@ -0,0 +1,57 @@
# -*- encoding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
import time
from . import Base
class SQLite3Cache(Base):
"""Implements a shared cache using SQLite3. Works across multiple processes
and threads, concurrent writes are not supported.
JSON is used to serialize python primitives in a safe way.
"""
serialize = True
def __init__(self, connection, threshold=1024, timeout=-1):
super(SQLite3Cache, self).__init__(threshold, timeout)
self.connection = connection
self.connection.execute(
'CREATE TABLE IF NOT EXISTS cache ('
' key TEXT PRIMARY KEY,'
' value BLOB,'
' time FLOAT)')
# drop trigger, in case threshold has changed
self.connection.execute('DROP TRIGGER IF EXISTS sweeper')
self.connection.execute([
'CREATE TRIGGER sweeper AFTER INSERT ON cache',
'BEGIN',
' DELETE FROM cache WHERE key NOT IN ('
' SELECT key FROM cache',
' ORDER BY time DESC LIMIT {0}'.format(threshold),
' );',
'END'])
def _get(self, ns, key, default=None):
rv = self.connection.execute(
'SELECT value FROM cache WHERE key = ?',
(ns + b'-' + key, )).fetchone()
if rv is None:
raise KeyError
return rv[0]
def _set(self, ns, key, value):
with self.connection.transaction as con:
con.execute(
'INSERT OR REPLACE INTO cache (key, value, time) VALUES (?, ?, ?)',
(ns + b'-' + key, value, time.time()))
def _delete(self, ns, key):
with self.connection.transaction as con:
con.execute('DELETE FROM cache WHERE key = ?', (ns + b'-' + key, ))

@ -0,0 +1,34 @@
# -*- encoding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
try:
import uwsgi
except ImportError:
uwsgi = None
from . import Base
class uWSGICache(Base):
"""Utilize uWSGI caching framework, in-memory and SMP-safe.
"""
serialize = True
def __init__(self, threshold=-1, timeout=3600):
if uwsgi is None:
raise RuntimeError("uWSGI not available")
super(uWSGICache, self).__init__(threshold, timeout)
def _get(self, ns, key):
if not uwsgi.cache_exists(key, ns):
raise KeyError
return uwsgi.cache_get(key, ns)
def _delete(self, ns, key):
uwsgi.cache_del(key, ns)
def _set(self, ns, key, value):
uwsgi.cache_set(key, value, self.timeout, ns)

@ -1,26 +1,28 @@
# -*- encoding: utf-8 -*-
try:
text_type = unicode # Python 2
string_types = (str, unicode)
PY2K = True
except NameError: # Python 3
PY2K = False
text_type = str
string_types = (str, )
import sys
PY2K = sys.version_info[0] == 2
if not PY2K:
buffer = memoryview
filter, map, zip = filter, map, zip
def iteritems(dikt):
return iter(dikt.items()) # noqa: E731
map, zip, filter = map, zip, filter
from functools import reduce
iteritems = lambda dikt: iter(dikt.items())
text_type = str
string_types = (str, )
buffer = memoryview
else:
buffer = buffer
from itertools import ifilter, imap, izip
filter, map, zip = ifilter, imap, izip
def iteritems(dikt):
return dikt.iteritems() # noqa: E731
from itertools import imap, izip, ifilter
map, zip, filter = imap, izip, ifilter
reduce = reduce
iteritems = lambda dikt: dikt.iteritems()
text_type = unicode
string_types = (str, unicode)
buffer = buffer

@ -7,10 +7,7 @@ import logging
import datetime
from email.utils import parseaddr, formataddr
try:
from backports.configparser import ConfigParser
except ImportError:
from configparser import ConfigParser
from configparser import ConfigParser
from isso.compat import text_type as str
@ -38,7 +35,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)
@ -93,10 +90,7 @@ class IssoParser(ConfigParser):
except ValueError:
return super(IssoParser, self).getint(section, key)
else:
try:
return int(delta.total_seconds())
except AttributeError:
return int(delta.total_seconds())
return int(delta.total_seconds())
def getlist(self, section, key):
return list(map(str.strip, self.get(section, key).split(',')))
@ -123,9 +117,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))
setify = lambda cp: set((section, option) for section in cp.sections()
for option in cp.options(section))
parser = new()
parser.read(default)

@ -0,0 +1,289 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import re
import time
from sqlalchemy.sql import func, select, not_
from isso.spam import Guard
from isso.utils import Bloomfilter
from isso.models import Comment
from isso.compat import string_types, buffer
class Invalid(Exception):
pass
class Denied(Exception):
pass
class Validator(object):
# from Django appearently, looks good to me *duck*
__url_re = re.compile(
r'^'
r'(https?://)?'
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)'
r'$', re.IGNORECASE)
@classmethod
def isurl(cls, text):
return Validator.__url_re.match(text) is not None
@classmethod
def verify(cls, comment):
if not isinstance(comment["parent"], (int, type(None))):
return False, "parent must be an integer or null"
if not isinstance(comment["text"], string_types):
return False, "text must be a string"
if len(comment["text"].rstrip()) < 3:
return False, "text is too short (minimum length: 3)"
for key in ("author", "email", "website"):
if not isinstance(comment[key], (string_types, type(None))):
return False, "%s must be a string or null" % key
if len(comment["email"] or "") > 254:
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
if comment["website"]:
if len(comment["website"]) > 254:
return False, "arbitrary length limit"
if not Validator.isurl(comment["website"]):
return False, "Website not Django-conform"
return True, ""
class Controller(object):
def __init__(self, db, guard=None):
if guard is None:
guard = Guard(db, enabled=False)
self.db = db
self.guard = guard
@classmethod
def Comment(cls, rv):
return Comment(
id=rv[0], parent=rv[1], thread=rv[2], created=rv[3], modified=rv[4],
mode=rv[5], remote_addr=rv[6], text=rv[7], author=rv[8], email=rv[9],
website=rv[10], likes=rv[11], dislikes=rv[12], voters=Bloomfilter(bytearray(rv[13])))
def new(self, remote_addr, thread, obj, moderated=False):
obj.setdefault("text", "")
obj.setdefault("parent", None)
for field in ("email", "author", "website"):
obj.setdefault(field, None)
valid, reason = Validator.verify(obj)
if not valid:
raise Invalid(reason)
if self.guard.enabled:
valid, reason = self.guard.validate(remote_addr, thread, obj)
if not valid:
raise Denied(reason)
obj["id"] = None
obj["thread"] = thread.id
obj["mode"] = 2 if moderated else 1
obj["created"] = time.time()
obj["modified"] = None
obj["remote_addr"] = remote_addr
obj["likes"] = obj["dislikes"] = 0
obj["voters"] = Bloomfilter(iterable=[remote_addr])
if obj["parent"] is not None:
parent = self.get(obj["parent"])
if parent is None:
obj["parent"] = None
elif parent.parent: # normalize to max depth of 1
obj["parent"] = parent.parent
obj = Comment(**obj)
_id = self.db.engine.execute(self.db.comments.insert()
.values((obj.id, obj.parent, obj.thread, obj.created, obj.modified,
obj.mode, obj.remote_addr, obj.text, obj.author, obj.email,
obj.website, obj.likes, obj.dislikes, buffer(obj.voters.array)))
).inserted_primary_key[0]
return obj.new(id=_id)
def edit(self, _id, new):
obj = self.get(_id)
if not obj:
return None
new.setdefault("text", "")
new.setdefault("parent", None)
for field in ("email", "author", "website"):
new.setdefault(field, None)
valid, reason = Validator.verify(new)
if not valid:
raise Invalid(reason)
obj = obj.new(text=new["text"], author=new["author"], email=new["email"],
website=new["website"], modified=time.time())
self.db.engine.execute(self.db.comments.update()
.values(text=obj.text, author=obj.author, email=obj.email,
website=obj.website, modified=obj.modified)
.where(self.db.comments.c.id == _id))
return obj
def get(self, _id):
"""Retrieve comment with :param id: if any.
"""
rv = self.db.engine.execute(
self.db.comments.select(self.db.comments.c.id == _id)).fetchone()
if not rv:
return None
return Controller.Comment(rv)
def all(self, thread, mode=1, after=0, parent='any', order_by='id', limit=None):
stmt = (self.db.comments.select()
.where(self.db.comments.c.thread == thread.id)
.where(self.db.comments.c.mode.op("|")(mode) == self.db.comments.c.mode)
.where(self.db.comments.c.created > after))
if parent != 'any':
stmt = stmt.where(self.db.comments.c.parent == parent)
stmt.order_by(getattr(self.db.comments.c, order_by))
if limit:
stmt.limit(limit)
return list(map(Controller.Comment, self.db.engine.execute(stmt).fetchall()))
def vote(self, remote_addr, _id, like):
"""Vote with +1 or -1 on comment :param id:
Returns True on success (in either direction), False if :param
remote_addr: has already voted. A comment can not be voted by its
original author.
"""
obj = self.get(_id)
if obj is None:
return False
if remote_addr in obj.voters:
return False
if like:
obj = obj.new(likes=obj.likes + 1)
else:
obj = obj.new(dislikes=obj.dislikes + 1)
obj.voters.add(remote_addr)
self.db.engine.execute(self.db.comments.update()
.values(likes=obj.likes, dislikes=obj.dislikes,
voters=buffer(obj.voters.array))
.where(self.db.comments.c.id == _id))
return True
def like(self, remote_addr, _id):
return self.vote(remote_addr, _id, like=True)
def dislike(self, remote_addr, _id):
return self.vote(remote_addr, _id, like=False)
def delete(self, _id):
"""
Delete comment with :param id:
If the comment is referenced by another (not yet deleted) comment, the
comment is *not* removed, but cleared and flagged as deleted.
"""
refs = self.db.engine.execute(
self.db.comments.select(self.db.comments.c.id).where(
self.db.comments.c.parent == _id)).fetchone()
if refs is None:
self.db.engine.execute(
self.db.comments.delete(self.db.comments.c.id == _id))
self.db.engine.execute(
self.db.comments.delete()
.where(self.db.comments.c.mode.op("|")(4) == self.db.comments.c.mode)
.where(not_(self.db.comments.c.id.in_(
select([self.db.comments.c.parent]).where(
self.db.comments.c.parent != None)))))
return None
obj = self.get(_id)
obj = obj.new(text="", author=None, email=None, website=None, mode=4)
self.db.engine.execute(self.db.comments.update()
.values(text=obj.text, author=obj.email, website=obj.website, mode=obj.mode)
.where(self.db.comments.c.id == _id))
return obj
def count(self, *threads):
"""Retrieve comment count for :param threads:
"""
ids = [getattr(th, "id", None) for th in threads]
threads = dict(
self.db.engine.execute(
select([self.db.comments.c.thread, func.count(self.db.comments)])
.where(self.db.comments.c.thread.in_(ids))
.group_by(self.db.comments.c.thread)).fetchall())
return [threads.get(_id, 0) for _id in ids]
def reply_count(self, thread, mode=5, after=0):
rv = self.db.engine.execute(
select([self.db.comments.c.parent, func.count(self.db.comments)])
.where(self.db.comments.c.thread == thread.id)
# .where(self.db.comments.c.mode.op("|")(mode) == mode)
.where(self.db.comments.c.created)
.group_by(self.db.comments.c.parent)).fetchall()
return dict(rv)
def activate(self, _id):
"""Activate comment :param id: and return True on success.
"""
obj = self.get(_id)
if obj is None:
return False
i = self.db.engine.execute(self.db.comments.update()
.values(mode=1)
.where(self.db.comments.c.id == _id)
.where(self.db.comments.c.mode == 2)).rowcount
return i > 0
def prune(self, delta):
"""Remove comments still in moderation queue older than max-age.
"""
now = time.time()
self.db.engine.execute(
self.db.comments.delete()
.where(self.db.comments.c.mode == 2)
.where(now - self.db.comments.c.created > delta))
return

@ -0,0 +1,36 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
from isso.models import Thread
class Controller(object):
def __init__(self, db):
self.db = db
def new(self, uri, title=None):
_id = self.db.engine.execute(
self.db.threads.insert().values(uri=uri, title=title)
).inserted_primary_key[0]
return Thread(_id, uri, title)
def get(self, uri):
rv = self.db.engine.execute(
self.db.threads.select(self.db.threads.c.uri == uri)).fetchone()
if not rv:
return None
return Thread(*rv)
def delete(self, uri):
thread = self.get(uri)
self.db.engine.execute(
self.db.comments.delete().where(self.db.comments.c.thread == thread.id))
self.db.engine.execute(
self.db.threads.delete().where(self.db.threads.c.id == thread.id))

@ -1,133 +0,0 @@
# -*- encoding: utf-8 -*-
from __future__ import print_function
import time
import logging
import threading
import multiprocessing
try:
import uwsgi
except ImportError:
uwsgi = None
from isso.compat import PY2K
if PY2K:
import thread
else:
import _thread as thread
from flask_caching.backends.null import NullCache
from flask_caching.backends.simple import SimpleCache
logger = logging.getLogger("isso")
class Cache:
"""Wrapper around werkzeug's cache class, to make it compatible to
uWSGI's cache framework.
"""
def __init__(self, cache):
self.cache = cache
def get(self, cache, key):
return self.cache.get(key)
def set(self, cache, key, value):
return self.cache.set(key, value)
def delete(self, cache, key):
return self.cache.delete(key)
class Mixin(object):
def __init__(self, conf):
self.lock = threading.Lock()
self.cache = Cache(NullCache())
def notify(self, subject, body, retries=5):
pass
def threaded(func):
"""
Decorator to execute each :param func: call in a separate thread.
"""
def dec(self, *args, **kwargs):
thread.start_new_thread(func, (self, ) + args, kwargs)
return dec
class ThreadedMixin(Mixin):
def __init__(self, conf):
super(ThreadedMixin, self).__init__(conf)
if conf.getboolean("moderation", "enabled"):
self.purge(conf.getint("moderation", "purge-after"))
self.cache = Cache(SimpleCache(threshold=1024, default_timeout=3600))
@threaded
def purge(self, delta):
while True:
with self.lock:
self.db.comments.purge(delta)
time.sleep(delta)
class ProcessMixin(ThreadedMixin):
def __init__(self, conf):
super(ProcessMixin, self).__init__(conf)
self.lock = multiprocessing.Lock()
class uWSGICache(object):
"""Uses uWSGI Caching Framework. INI configuration:
.. code-block:: ini
cache2 = name=hash,items=1024,blocksize=32
"""
@classmethod
def get(self, cache, key):
return uwsgi.cache_get(key, cache)
@classmethod
def set(self, cache, key, value):
uwsgi.cache_set(key, value, 3600, cache)
@classmethod
def delete(self, cache, key):
uwsgi.cache_del(key, cache)
class uWSGIMixin(Mixin):
def __init__(self, conf):
super(uWSGIMixin, self).__init__(conf)
self.lock = multiprocessing.Lock()
self.cache = uWSGICache
timedelta = conf.getint("moderation", "purge-after")
def purge(signum):
return self.db.comments.purge(timedelta)
uwsgi.register_signal(1, "", purge)
uwsgi.add_timer(1, timedelta)
# run purge once
purge(1)

@ -1,134 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-style: normal;
font-weight: normal;
}
input {
text-align: center;
}
.header::before, .header::after {
content: " ";
display: table;
}
.header::after {
clear: both;
}
.header::before, .header::after {
content: " ";
display: table;
}
.header {
margin-left: auto;
margin-right: auto;
max-width: 68em;
padding-bottom: 1em;
padding-top: 1em;
}
.header header {
display: block;
float: left;
font-weight: normal;
margin-right: 16.0363%;
width: 41.9818%;
}
.header header .logo {
float: left;
max-height: 60px;
padding-right: 12px;
}
.header header h1 {
font-size: 1.55em;
margin-bottom: 0.3em;
}
.header header h2 {
font-size: 1.05em;
}
.header a, .header a:visited {
color: #4d4c4c;
text-decoration: none;
}
.outer {
background-color: #eeeeee;
box-shadow: 0 0 0.5em #c0c0c0 inset;
}
.outer .filters::before, .outer .filters::after {
content: " ";
display: table;
}
.outer .filters::after {
clear: both;
}
.outer .filters::before, .outer .filters::after {
content: " ";
display: table;
}
.outer .filters {
margin-left: auto;
margin-right: auto;
max-width: 68em;
padding: 1em;
}
a {
text-decoration: none;
color: #4d4c4c;
}
.label {
background-color: #ddd;
border: 1px solid #ccc;
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
cursor: pointer;
line-height: 1.4em;
outline: 0 none;
padding: calc(0.6em - 1px);
}
.active {
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6) inset;
}
.label-valid {
background-color: #cfc;
border-color: #cfc;
}
.label-pending {
background-color: #ffc;
border-color: #ffc;
}
.mode {
float: left;
}
.pagination {
float: right;
}
.note .label {
margin: 9px;
padding: 3px;
}
#login {
margin-top: 40px;
text-align: center;
width: 100%;
}
.isso-comment-footer a {
cursor: pointer;
}
.thread-title {
margin-left: 3em;
}
.group {
float: left;
margin-left: 2em;
}
.editable {
border: 1px solid #aaa;
border-radius: 5px;
margin: 10px;
padding: 5px;
}
.hidden {
display: none;
}

@ -3,7 +3,7 @@
-moz-box-sizing: border-box;
box-sizing: border-box;
}
#isso-thread .isso-comment-header a {
#isso-thread a {
text-decoration: none;
}
@ -15,42 +15,39 @@
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;
}
#isso-thread .textarea.placeholder {
color: #757575;
color: #AAA;
}
#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,21 +119,18 @@
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;
}
.isso-comment > div.text-wrapper > .isso-comment-footer > a {
position: relative;
top: .2em;
}
.isso-comment > div.text-wrapper > .isso-comment-footer > a + a {
.isso-comment > div.text-wrapper > .isso-comment-footer a.reply,
.isso-comment > div.text-wrapper > .isso-comment-footer a.edit,
.isso-comment > div.text-wrapper > .isso-comment-footer a.cancel,
.isso-comment > div.text-wrapper > .isso-comment-footer a.delete {
padding-left: 1em;
}
.isso-comment > div.text-wrapper > .isso-comment-footer .votes {
@ -152,14 +144,10 @@
.isso-comment .isso-postbox {
margin-top: 0.8em;
}
.isso-comment.isso-no-votes > * > .isso-comment-footer span.votes {
display: none;
}
.isso-postbox {
max-width: 68em;
margin: 0 auto 2em;
clear: right;
}
.isso-postbox > .form-wrapper {
display: block;
@ -169,8 +157,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 +165,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 +187,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 +205,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 +214,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;
}
}

@ -1,23 +1,164 @@
# -*- encoding: utf-8 -*-
import sqlite3
from __future__ import unicode_literals
import logging
import sqlite3
import binascii
import operator
import threading
import os.path
from collections import defaultdict
from sqlalchemy import Table, Column, MetaData, create_engine
from sqlalchemy import ForeignKey, Integer, Float, String, LargeBinary
from sqlalchemy.sql import select
logger = logging.getLogger("isso")
from isso.compat import buffer
from isso.db.comments import Comments
from isso.db.threads import Threads
from isso.db.spam import Guard
from isso.db.preferences import Preferences
class Adapter(object):
def __init__(self, db):
self.engine = create_engine(db, echo=False)
self.metadata = MetaData()
self.comments = Table("comments", self.metadata,
Column("id", Integer, primary_key=True),
Column("parent", Integer),
Column("thread", None, ForeignKey("threads.id")),
Column("created", Float),
Column("modified", Float),
Column("mode", Integer),
Column("remote_addr", String(48)), # XXX use a BigInt
Column("text", String(65535)),
Column("author", String(255)),
Column("email", String(255)),
Column("website", String(255)),
Column("likes", Integer),
Column("dislikes", Integer),
Column("voters", LargeBinary(256)))
self.threads = Table("threads", self.metadata,
Column("id", Integer, primary_key=True),
Column("uri", String(255), unique=True),
Column("title", String(255)))
preferences = Table("preferences", self.metadata,
Column("key", String(255), primary_key=True),
Column("value", String(255)))
self.metadata.create_all(self.engine)
self.preferences = Preferences(self.engine, preferences)
@property
def transaction(self):
return self.engine.begin()
class Preferences(object):
"""A simple key-value store using SQL.
"""
defaults = [
("session-key", binascii.b2a_hex(os.urandom(24))),
]
def __init__(self, engine, preferences):
self.engine = engine
self.preferences = preferences
for (key, value) in Preferences.defaults:
if self.get(key) is None:
self.set(key, value)
def get(self, key, default=None):
rv = self.engine.execute(
select([self.preferences.c.value])
.where(self.preferences.c.key == key)).fetchone()
class SQLite3:
if rv is None:
return default
return rv[0]
def set(self, key, value):
self.engine.execute(
self.preferences.insert().values(
key=key, value=value))
class Transaction(object):
"""A context manager to lock the database across processes and automatic
rollback on failure. On success, reset the isolation level back to normal.
SQLite3's DEFERRED (default) transaction mode causes database corruption
for concurrent writes to the database from multiple processes. IMMEDIATE
ensures a global write lock, but reading is still possible.
"""
def __init__(self, con):
self.con = con
def __enter__(self):
self._orig = self.con.isolation_level
self.con.isolation_level = "IMMEDIATE"
self.con.execute("BEGIN IMMEDIATE")
return self.con
def __exit__(self, exc_type, exc_val, exc_tb):
try:
if exc_type:
self.con.rollback()
else:
self.con.commit()
finally:
self.con.isolation_level = self._orig
class SQLite3(object):
"""SQLite3 connection pool across multiple threads. Implementation idea
from `Peewee <https://github.com/coleifer/peewee>`_.
"""
def __init__(self, db):
self.db = os.path.expanduser(db)
self.lock = threading.Lock()
self.local = threading.local()
def connect(self):
with self.lock:
self.local.conn = sqlite3.connect(self.db, isolation_level=None)
def close(self):
with self.lock:
self.local.conn.close()
self.local.conn = None
def execute(self, sql, args=()):
if isinstance(sql, (list, tuple)):
sql = ' '.join(sql)
return self.connection.execute(sql, args)
@property
def connection(self):
if not hasattr(self.local, 'conn') or self.local.conn is None:
self.connect()
return self.local.conn
@property
def transaction(self):
return Transaction(self.connection)
@property
def total_changes(self):
return self.connection.total_changes
class Foo(object):
"""DB-dependend wrapper around SQLite3.
Runs migration if `user_version` is older than `MAX_VERSION` and register
@ -26,9 +167,8 @@ class SQLite3:
MAX_VERSION = 3
def __init__(self, path, conf):
self.path = os.path.expanduser(path)
def __init__(self, conn, conf):
self.connection = conn
self.conf = conf
rv = self.execute([
@ -42,9 +182,9 @@ class SQLite3:
self.guard = Guard(self)
if rv is None:
self.execute("PRAGMA user_version = %i" % SQLite3.MAX_VERSION)
self.execute("PRAGMA user_version = %i" % Adapter.MAX_VERSION)
else:
self.migrate(to=SQLite3.MAX_VERSION)
self.migrate(to=Adapter.MAX_VERSION)
self.execute([
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
@ -53,14 +193,6 @@ class SQLite3:
' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);',
'END'])
def execute(self, sql, args=()):
if isinstance(sql, (list, tuple)):
sql = ' '.join(sql)
with sqlite3.connect(self.path) as con:
return con.execute(sql, args)
@property
def version(self):
return self.execute("PRAGMA user_version").fetchone()[0]
@ -79,7 +211,7 @@ class SQLite3:
from isso.utils import Bloomfilter
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
with sqlite3.connect(self.path) as con:
with self.connection.transaction as con:
con.execute('UPDATE comments SET voters=?', (bf, ))
con.execute('PRAGMA user_version = 1')
logger.info("%i rows changed", con.total_changes)
@ -87,7 +219,7 @@ class SQLite3:
# move [general] session-key to database
if self.version == 1:
with sqlite3.connect(self.path) as con:
with self.connection.transaction as con:
if self.conf.has_option("general", "session-key"):
con.execute('UPDATE preferences SET value=? WHERE key=?', (
self.conf.get("general", "session-key"), "session-key"))
@ -98,12 +230,10 @@ class SQLite3:
# limit max. nesting level to 1
if self.version == 2:
def first(rv):
return list(map(operator.itemgetter(0), rv))
first = lambda rv: list(map(operator.itemgetter(0), rv))
with sqlite3.connect(self.path) as con:
top = first(con.execute(
"SELECT id FROM comments WHERE parent IS NULL").fetchall())
with self.connection.transaction as con:
top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall())
flattened = defaultdict(set)
for id in top:
@ -111,15 +241,16 @@ class SQLite3:
ids = [id, ]
while ids:
rv = first(con.execute(
"SELECT id FROM comments WHERE parent=?", (ids.pop(), )))
rv = first(con.execute("SELECT id FROM comments WHERE parent=?", (ids.pop(), )))
ids.extend(rv)
flattened[id].update(set(rv))
for id in flattened.keys():
for n in flattened[id]:
con.execute(
"UPDATE comments SET parent=? WHERE id=?", (id, n))
con.execute("UPDATE comments SET parent=? WHERE id=?", (id, n))
con.execute('PRAGMA user_version = 3')
logger.info("%i rows changed", con.total_changes)
def execute(self, sql, args=()):
return self.connection.execute(sql, args)

@ -1,348 +0,0 @@
# -*- 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:
| tid (thread id) | id (comment id) | parent | ... | voters | remote_addr |
+-----------------+-----------------+--------+-----+--------+-------------+
| 1 | 1 | null | ... | BLOB | 127.0.0.0 |
| 1 | 2 | 1 | ... | BLOB | 127.0.0.0 |
+-----------------+-----------------+--------+-----+--------+-------------+
The tuple (tid, id) is unique and thus primary key.
"""
fields = ['tid', 'id', 'parent', 'created', 'modified',
'mode', # status of the comment 1 = valid, 2 = pending,
# 4 = soft-deleted (cannot hard delete because of replies)
'remote_addr', 'text', 'author', 'email', 'website',
'likes', 'dislikes', 'voters', 'notification']
def __init__(self, db):
self.db = db
self.db.execute([
'CREATE TABLE IF NOT EXISTS 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
def add(self, uri, c):
"""
Add new comment to DB and return a mapping of :attribute:`fields` and
database values.
"""
if c.get("parent") is not None:
ref = self.get(c["parent"])
if ref.get("parent") is not None:
c["parent"] = ref["parent"]
self.db.execute([
'INSERT INTO comments (',
' tid, parent,'
' created, modified, mode, remote_addr,',
' text, author, email, website, voters, notification)',
'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'),
uri)
)
return dict(zip(Comments.fields, self.db.execute(
'SELECT *, MAX(c.id) FROM comments AS c INNER JOIN threads ON threads.uri = ?',
(uri, )).fetchone()))
def activate(self, id):
"""
Activate comment id if pending.
"""
self.db.execute([
'UPDATE comments SET',
' 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
updated comment.
"""
self.db.execute([
'UPDATE comments SET',
','.join(key + '=' + '?' for key in data),
'WHERE id=?;'],
list(data.values()) + [id])
return self.get(id)
def get(self, id):
"""
Search for comment :param:`id` and return a mapping of :attr:`fields`
and values.
"""
rv = self.db.execute(
'SELECT * FROM comments WHERE id=?', (id, )).fetchone()
if rv:
return dict(zip(Comments.fields, rv))
return None
def count_modes(self):
"""
Return comment mode counts for admin
"""
comment_count = self.db.execute(
'SELECT mode, COUNT(comments.id) FROM comments '
'GROUP BY comments.mode').fetchall()
return dict(comment_count)
def fetchall(self, mode=5, after=0, parent='any', order_by='id',
limit=100, page=0, asc=1):
"""
Return comments for admin with :param:`mode`.
"""
fields_comments = ['tid', 'id', 'parent', 'created', 'modified',
'mode', 'remote_addr', 'text', 'author',
'email', 'website', 'likes', 'dislikes']
fields_threads = ['uri', 'title']
sql_comments_fields = ', '.join(['comments.' + f
for f in fields_comments])
sql_threads_fields = ', '.join(['threads.' + f
for f in fields_threads])
sql = ['SELECT ' + sql_comments_fields + ', ' + sql_threads_fields + ' '
'FROM comments INNER JOIN threads '
'ON comments.tid=threads.id '
'WHERE comments.mode = ? ']
sql_args = [mode]
if parent != 'any':
if parent is None:
sql.append('AND comments.parent IS NULL')
else:
sql.append('AND comments.parent=?')
sql_args.append(parent)
# custom sanitization
if order_by not in ['id', 'created', 'modified', 'likes', 'dislikes', 'tid']:
sql.append('ORDER BY ')
sql.append("comments.created")
if not asc:
sql.append(' DESC')
else:
sql.append('ORDER BY ')
sql.append('comments.' + order_by)
if not asc:
sql.append(' DESC')
sql.append(", comments.created")
if limit:
sql.append('LIMIT ?,?')
sql_args.append(page * limit)
sql_args.append(limit)
rv = self.db.execute(sql, sql_args).fetchall()
for item in rv:
yield dict(zip(fields_comments + fields_threads, item))
def fetch(self, uri, mode=5, after=0, parent='any',
order_by='id', asc=1, limit=None):
"""
Return comments for :param:`uri` with :param:`mode`.
"""
sql = ['SELECT comments.* FROM comments INNER JOIN threads ON',
' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?',
' AND comments.created>?']
sql_args = [uri, mode, mode, after]
if parent != 'any':
if parent is None:
sql.append('AND comments.parent IS NULL')
else:
sql.append('AND comments.parent=?')
sql_args.append(parent)
# custom sanitization
if order_by not in ['id', 'created', 'modified', 'likes', 'dislikes']:
order_by = 'id'
sql.append('ORDER BY ')
sql.append(order_by)
if not asc:
sql.append(' DESC')
if limit:
sql.append('LIMIT ?')
sql_args.append(limit)
rv = self.db.execute(sql, sql_args).fetchall()
for item in rv:
yield dict(zip(Comments.fields, item))
def _remove_stale(self):
sql = ('DELETE FROM',
' comments',
'WHERE',
' mode=4 AND id NOT IN (',
' SELECT',
' parent',
' FROM',
' comments',
' WHERE parent IS NOT NULL)')
while self.db.execute(sql).rowcount:
continue
def delete(self, id):
"""
Delete a comment. There are two distinctions: a comment is referenced
by another valid comment's parent attribute or stand-a-lone. In this
case the comment can't be removed without losing depending comments.
Hence, delete removes all visible data such as text, author, email,
website sets the mode field to 4.
In the second case this comment can be safely removed without any side
effects."""
refs = self.db.execute(
'SELECT * FROM comments WHERE parent=?', (id, )).fetchone()
if refs is None:
self.db.execute('DELETE FROM comments WHERE id=?', (id, ))
self._remove_stale()
return None
self.db.execute('UPDATE comments SET text=? WHERE id=?', ('', id))
self.db.execute('UPDATE comments SET mode=? WHERE id=?', (4, id))
for field in ('author', 'website'):
self.db.execute('UPDATE comments SET %s=? WHERE id=?' %
field, (None, id))
self._remove_stale()
return self.get(id)
def vote(self, upvote, id, remote_addr):
"""+1 a given comment. Returns the new like count (may not change because
the creater can't vote on his/her own comment and multiple votes from the
same ip address are ignored as well)."""
rv = self.db.execute(
'SELECT likes, dislikes, voters FROM comments WHERE id=?', (id, )) \
.fetchone()
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}
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}
bf.add(remote_addr)
self.db.execute([
'UPDATE comments SET',
' likes = likes + 1,' if upvote else 'dislikes = dislikes + 1,',
' voters = ?'
'WHERE id=?;'], (buffer(bf.array), id))
if upvote:
return {'likes': likes + 1, 'dislikes': dislikes}
return {'likes': likes, 'dislikes': dislikes + 1}
def reply_count(self, url, mode=5, after=0):
"""
Return comment count for main thread and all reply threads for one url.
"""
sql = ['SELECT comments.parent,count(*)',
'FROM comments INNER JOIN threads ON',
' threads.uri=? AND comments.tid=threads.id AND',
' (? | comments.mode = ?) AND',
' comments.created > ?',
'GROUP BY comments.parent']
return dict(self.db.execute(sql, [url, mode, mode, after]).fetchall())
def count(self, *urls):
"""
Return comment count for one ore more urls..
"""
threads = dict(self.db.execute([
'SELECT threads.uri, COUNT(comments.id) FROM comments',
'LEFT OUTER JOIN threads ON threads.id = tid AND comments.mode = 1',
'GROUP BY threads.uri'
]).fetchall())
return [threads.get(url, 0) for url in urls]
def purge(self, delta):
"""
Remove comments older than :param:`delta`.
"""
self.db.execute([
'DELETE FROM comments WHERE mode = 2 AND ? - created > ?;'
], (time.time(), delta))
self._remove_stale()

@ -1,36 +0,0 @@
# -*- encoding: utf-8 -*-
import os
import binascii
class Preferences:
defaults = [
("session-key", binascii.b2a_hex(os.urandom(24)).decode('utf-8')),
]
def __init__(self, db):
self.db = db
self.db.execute([
'CREATE TABLE IF NOT EXISTS preferences (',
' key VARCHAR PRIMARY KEY, value VARCHAR',
');'])
for (key, value) in Preferences.defaults:
if self.get(key) is None:
self.set(key, value)
def get(self, key, default=None):
rv = self.db.execute(
'SELECT value FROM preferences WHERE key=?', (key, )).fetchone()
if rv is None:
return default
return rv[0]
def set(self, key, value):
self.db.execute(
'INSERT INTO preferences (key, value) VALUES (?, ?)', (key, value))

@ -1,76 +0,0 @@
# -*- encoding: utf-8 -*-
import time
class Guard:
def __init__(self, db):
self.db = db
self.conf = db.conf.section("guard")
self.max_age = db.conf.getint("general", "max-age")
def validate(self, uri, comment):
if not self.conf.getboolean("enabled"):
return True, ""
for func in (self._limit, self._spam):
valid, reason = func(uri, comment)
if not valid:
return False, reason
return True, ""
@classmethod
def ids(cls, rv):
return [str(col[0]) for col in rv]
def _limit(self, uri, comment):
# block more than :param:`ratelimit` comments per minute
rv = self.db.execute([
'SELECT id FROM comments WHERE remote_addr = ? AND ? - created < 60;'
], (comment["remote_addr"], time.time())).fetchall()
if len(rv) >= self.conf.getint("ratelimit"):
return False, "{0}: ratelimit exceeded ({1})".format(
comment["remote_addr"], ', '.join(Guard.ids(rv)))
# block more than three comments as direct response to the post
if comment["parent"] is None:
rv = self.db.execute([
'SELECT id FROM comments WHERE',
' tid = (SELECT id FROM threads WHERE uri = ?)',
'AND remote_addr = ?',
'AND parent IS NULL;'
], (uri, comment["remote_addr"])).fetchall()
if len(rv) >= self.conf.getint("direct-reply"):
return False, "%i direct responses to %s" % (len(rv), uri)
# block replies to self unless :param:`reply-to-self` is enabled
elif self.conf.getboolean("reply-to-self") is False:
rv = self.db.execute([
'SELECT id FROM comments WHERE'
' remote_addr = ?',
'AND id = ?',
'AND ? - created < ?'
], (comment["remote_addr"], comment["parent"],
time.time(), self.max_age)).fetchall()
if len(rv) > 0:
return False, "edit time frame is still open"
# require email if :param:`require-email` is enabled
if self.conf.getboolean("require-email") and not comment.get("email"):
return False, "email address required but not provided"
# require author if :param:`require-author` is enabled
if self.conf.getboolean("require-author") and not comment.get("author"):
return False, "author address required but not provided"
return True, ""
def _spam(self, uri, comment):
return True, ""

@ -1,34 +0,0 @@
# -*- encoding: utf-8 -*-
def Thread(id, uri, title):
return {
"id": id,
"uri": uri,
"title": title
}
class Threads(object):
def __init__(self, db):
self.db = db
self.db.execute([
'CREATE TABLE IF NOT EXISTS threads (',
' id INTEGER PRIMARY KEY, uri VARCHAR(256) UNIQUE, title VARCHAR(256))'])
def __contains__(self, uri):
return self.db.execute("SELECT title FROM threads WHERE uri=?", (uri, )) \
.fetchone() is not None
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))
return self[uri]

@ -7,7 +7,6 @@
<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.html">Isso Demo</a></h2>
<script src="../js/embed.min.js"></script>
<section id="isso-thread" data-title="Isso Test"></section>

@ -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
@ -22,16 +22,17 @@ class Dispatcher(DispatcherMiddleware):
a relative URI, e.g. /foo.example and /other.bar.
"""
conf = os.path.join(dist.location, "isso", "defaults.ini")
def __init__(self, *confs):
self.isso = {}
default = os.path.join(
dist.location, dist.project_name, "defaults.ini")
for i, path in enumerate(confs):
conf = config.load(default, path)
for path in confs:
conf = config.load(Dispatcher.conf, path)
if not conf.get("general", "name"):
logger.warn("unable to dispatch %r, no 'name' set", confs[i])
logger.warn("unable to dispatch %r, no 'name' set", path)
continue
self.isso["/" + conf.get("general", "name")] = make_app(conf)
@ -46,8 +47,7 @@ class Dispatcher(DispatcherMiddleware):
return super(Dispatcher, self).__call__(environ, start_response)
def default(self, environ, start_response):
resp = Response("\n".join(self.isso.keys()),
404, content_type="text/plain")
resp = Response("\n".join(self.isso.keys()), 404, content_type="text/plain")
return resp(environ, start_response)

@ -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,59 +31,16 @@ else:
from _thread import start_new_thread
class SMTPConnection(object):
def __init__(self, conf):
self.conf = conf
def __enter__(self):
klass = (smtplib.SMTP_SSL if self.conf.get(
'security') == 'ssl' else smtplib.SMTP)
self.client = klass(host=self.conf.get('host'),
port=self.conf.getint('port'),
timeout=self.conf.getint('timeout'))
if self.conf.get('security') == 'starttls':
if sys.version_info >= (3, 4):
import ssl
self.client.starttls(context=ssl.create_default_context())
else:
self.client.starttls()
username = self.conf.get('username')
password = self.conf.get('password')
if username and password:
if PY2K:
username = username.encode('ascii')
password = password.encode('ascii')
self.client.login(username, password)
return self.client
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):
with self:
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.exception("unable to connect to SMTP server")
@ -97,8 +49,7 @@ class SMTP(object):
def spooler(args):
try:
self._sendmail(args[b"subject"].decode("utf-8"),
args["body"].decode("utf-8"),
args[b"to"].decode("utf-8"))
args["body"].decode("utf-8"))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
@ -106,16 +57,37 @@ class SMTP(object):
uwsgi.spooler = spooler
def __enter__(self):
klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP)
self.client = klass(host=self.conf.get('host'),
port=self.conf.getint('port'),
timeout=self.conf.getint('timeout'))
if self.conf.get('security') == 'starttls':
if sys.version_info >= (3, 4):
import ssl
self.client.starttls(context=ssl.create_default_context())
else:
self.client.starttls()
if self.conf.get('username') and self.conf.get('password'):
self.client.login(self.conf.get('username'),
self.conf.get('password'))
return self.client
def __exit__(self, exc_type, exc_value, traceback):
self.client.quit()
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 +95,39 @@ 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("Link to comment: %s\n" %
(local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
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"])
uri = local("host") + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
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 +135,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:
@ -237,11 +168,10 @@ class Stdout(object):
logger.info("comment created: %s", json.dumps(comment))
def _edit_comment(self, comment):
logger.info('comment %i edited: %s',
comment["id"], json.dumps(comment))
logger.info('comment %i edited: %s', comment["id"], json.dumps(comment))
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)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

@ -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");
@ -52,9 +52,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
}
if (xhr.status >= 500) {
if (reject) {
reject(xhr.body);
}
reject(xhr.body);
} else {
resolve({status: xhr.status, body: xhr.responseText});
}
@ -91,14 +89,8 @@ 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),
function (rv) {
if (rv.status === 201 || rv.status === 202) {
deferred.resolve(JSON.parse(rv.body));
} else {
deferred.reject(rv.body);
}
});
curl("POST", endpoint + "/new?" + qs({uri: tid || location}), JSON.stringify(data),
function (rv) { deferred.resolve(JSON.parse(rv.body)); });
return deferred.promise;
};
@ -142,7 +134,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 +183,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 +194,6 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
fetch: fetch,
count: count,
like: like,
dislike: dislike,
feed: feed,
preview: preview
dislike: dislike
};
});

@ -5,20 +5,15 @@ define(function() {
"css": true,
"lang": (navigator.language || navigator.userLanguage).split("-")[0],
"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,
"auth": false,
"auth-sign-in-url": "",
"avatar": true,
"avatar-bg": "#f0f0f0",
"avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6",
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
"vote": true,
"vote-levels": null,
"feed": false
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" ")
};
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;
}

@ -2,137 +2,101 @@ define(function() {
"use strict";
function Element(node) {
this.obj = node;
this.replace = function (el) {
var element = DOM.htmlify(el);
node.parentNode.replaceChild(element.obj, node);
return element;
};
this.prepend = function (el) {
var element = DOM.htmlify(el);
node.insertBefore(element.obj, node.firstChild);
return element;
};
window.Element.prototype.replace = function(el) {
var element = DOM.htmlify(el);
this.parentNode.replaceChild(element, this);
return element;
};
this.append = function (el) {
var element = DOM.htmlify(el);
node.appendChild(element.obj);
return element;
};
window.Element.prototype.prepend = function(el) {
var element = DOM.htmlify(el);
this.insertBefore(element, this.firstChild);
return element;
};
this.insertAfter = function(el) {
var element = DOM.htmlify(el);
node.parentNode.insertBefore(element.obj, node.nextSibling);
return element;
};
window.Element.prototype.append = function(el) {
var element = DOM.htmlify(el);
this.appendChild(element);
return element;
};
/**
* Shortcut for `Element.addEventListener`, prevents default event
* by default, set :param prevents: to `false` to change that behavior.
*/
this.on = function(type, listener, prevent) {
node.addEventListener(type, function(event) {
listener(event);
if (prevent === undefined || prevent) {
event.preventDefault();
}
});
};
window.Element.prototype.insertAfter = function(el) {
var element = DOM.htmlify(el);
this.parentNode.insertBefore(element, this.nextSibling);
return element;
};
/**
* Toggle between two internal states on event :param type: e.g. to
* cycle form visibility. Callback :param a: is called on first event,
* :param b: next time.
*
* You can skip to the next state without executing the callback with
* `toggler.next()`. You can prevent a cycle when you call `toggler.wait()`
* during an event.
window.Element.prototype.on = function(type, listener, prevent) {
/*
Shortcut for `Element.addEventListener`, prevents default event
by default, set :param prevents: to `false` to change that behavior.
*/
this.toggle = function(type, a, b) {
var toggler = new Toggle(a, b);
this.on(type, function() {
toggler.next();
});
};
this.detach = function() {
// Detach an element from the DOM and return it.
node.parentNode.removeChild(this.obj);
return this;
};
this.remove = function() {
// IE quirks
node.parentNode.removeChild(this.obj);
};
this.show = function() {
node.style.display = "block";
};
this.hide = function() {
node.style.display = "none";
};
this.setText = function(text) {
node.textContent = text;
};
this.setHtml = function(html) {
node.innerHTML = html;
};
this.blur = function() { node.blur() };
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) };
this.classList = node.classList;
Object.defineProperties(this, {
"textContent": {
get: function() { return node.textContent; },
set: function(textContent) { node.textContent = textContent; }
},
"innerHTML": {
get: function() { return node.innerHTML; },
set: function(innerHTML) { node.innerHTML = innerHTML; }
},
"value": {
get: function() { return node.value; },
set: function(value) { node.value = value; }
},
"placeholder": {
get: function() { return node.placeholder; },
set: function(placeholder) { node.placeholder = placeholder; }
this.addEventListener(type, function(event) {
listener(event);
if (prevent === undefined || prevent) {
event.preventDefault();
}
});
}
};
window.Element.prototype.toggle = function(type, on, off) {
/*
Toggle between two internal states on event :param type: e.g. to
cycle form visibility. Callback :param on: is called on first event,
:param off: next time.
var Toggle = function(a, b) {
this.state = false;
You can skip to the next state without executing the callback with
`toggler.next()`. You can prevent a cycle when you call `toggler.wait()`
during an event.
*/
function Toggle(el, on, off) {
this.state = false;
this.el = el;
this.on = on;
this.off = off;
}
this.next = function() {
Toggle.prototype.next = function next() {
if (! this.state) {
this.state = true;
a(this);
this.on(this);
} else {
this.state = false;
b(this);
this.off(this);
}
};
this.wait = function() {
Toggle.prototype.wait = function wait() {
this.state = ! this.state;
};
var toggler = new Toggle(this, on, off);
this.on(type, function() {
toggler.next();
});
};
window.Element.prototype.detach = function() {
/*
Detach an element from the DOM and return it.
*/
this.parentNode.removeChild(this);
return this;
};
window.Element.prototype.remove = function() {
// Mimimi, I am IE and I am so retarded, mimimi.
this.parentNode.removeChild(this);
};
window.Element.prototype.show = function() {
this.style.display = "block";
};
window.Element.prototype.hide = function() {
this.style.display = "none";
};
var DOM = function(query, root, single) {
@ -151,43 +115,31 @@ define(function() {
root = window.document;
}
if (root instanceof Element) {
root = root.obj;
}
var elements = [].slice.call(root.querySelectorAll(query), 0);
var elements = root.querySelectorAll(query);
if (elements.length === 0) {
return null;
}
if (elements.length === 1 && single) {
return new Element(elements[0]);
return elements[0];
}
// convert NodeList to Array
elements = [].slice.call(elements, 0);
return elements.map(function(el) {
return new Element(el);
});
return elements;
};
DOM.htmlify = function(el) {
DOM.htmlify = function(html) {
/*
Convert :param html: into an Element (if not already).
*/
if (el instanceof Element) {
return el;
}
*/
if (el instanceof window.Element) {
return new Element(el);
if (html instanceof window.Element) {
return html;
}
var wrapper = DOM.new("div");
wrapper.innerHTML = el;
return new Element(wrapper.firstChild);
wrapper.innerHTML = html;
return wrapper.firstChild;
};
DOM.new = function(tag, content) {
@ -207,13 +159,10 @@ define(function() {
el.href = "#";
}
if (!content && content !== 0) {
content = "";
}
if (["TEXTAREA", "INPUT"].indexOf(el.nodeName) > -1) {
el.value = content;
el.value = content || "";
} else {
el.textContent = content;
el.textContent = content || "";
}
return el;
};
@ -224,4 +173,4 @@ define(function() {
};
return DOM;
});
});

@ -10,7 +10,7 @@ define(function() {
};
Offset.prototype.localTime = function() {
return new Date((new Date()).getTime() - this.values.reduce(
return new Date((new Date()).getTime() + this.values.reduce(
function(a, b) { return a + b; }) / this.values.length);
};
@ -18,4 +18,4 @@ define(function() {
offset: new Offset()
};
});
});

@ -1,34 +1,13 @@
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/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) {
define(["app/config", "app/i18n/de", "app/i18n/en", "app/i18n/fr", "app/i18n/ru", "app/i18n/it", "app/i18n/eo"], function(config, de, en, fr, ru, it, eo) {
"use strict";
var pluralforms = function(lang) {
switch (lang) {
case "bg":
case "cs":
case "da":
case "de":
case "el":
case "en":
case "es":
case "eo":
case "fa":
case "fi":
case "hr":
case "hu":
case "de":
case "it":
case "pt_BR":
case "sv":
case "nl":
case "vi":
case "zh":
case "zh_CN":
case "zh_TW":
case "eo":
return function(msgs, n) {
return msgs[n === 1 ? 0 : 1];
};
@ -46,30 +25,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;
}
@ -84,41 +39,18 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
}
var catalogue = {
bg: bg,
cs: cs,
da: da,
de: de,
el: el,
en: en,
eo: eo,
es: es,
fa: fa,
fi: fi,
fr: fr,
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,
zh: zh_CN,
zh_CN: zh_CN,
zh_TW: zh_TW
it: it,
eo: eo
};
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) {

@ -1,29 +0,0 @@
define({
"postbox-text": "Въведете коментара си тук (поне 3 знака)",
"postbox-author": "Име/псевдоним (незадължително)",
"postbox-email": "Ел. поща (незадължително)",
"postbox-website": "Уебсайт (незадължително)",
"postbox-preview": "преглед",
"postbox-edit": "Редактиране",
"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 }} години"
});

@ -1,29 +0,0 @@
define({
"postbox-text": "Sem napiště svůj komentář (nejméně 3 znaky)",
"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ářů",
"comment-reply": "Odpovědět",
"comment-edit": "Upravit",
"comment-save": "Uložit",
"comment-delete": "Smazat",
"comment-confirm": "Potvrdit",
"comment-close": "Zavřít",
"comment-cancel": "Zrušit",
"comment-deleted": "Komentář smazán",
"comment-queued": "Komentář ve frontě na schválení",
"comment-anonymous": "Anonym",
"comment-hidden": "{{ n }} skryto",
"date-now": "právě teď",
"date-minute": "před minutou\npřed {{ n }} minutami",
"date-hour": "před hodinou\npřed {{ n }} hodinami",
"date-day": "včera\npřed {{ n }} dny",
"date-week": "minulý týden\npřed {{ n }} týdny",
"date-month": "minulý měsíc\npřed {{ n }} měsíci",
"date-year": "minulý rok\npřed {{ n }} lety"
});

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

@ -1,14 +1,11 @@
define({
"postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)",
"postbox-text": "Kommentar hier eintippen (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",
"no-comments": "Keine Kommentare bis jetzt",
"comment-reply": "Antworten",
"comment-edit": "Bearbeiten",
"comment-save": "Speichern",
@ -20,7 +17,7 @@ define({
"comment-queued": "Kommentar muss noch freigeschaltet werden.",
"comment-anonymous": "Anonym",
"comment-hidden": "{{ n }} versteckt",
"date-now": "eben gerade",
"date-now": "eben jetzt",
"date-minute": "vor einer Minute\nvor {{ n }} Minuten",
"date-hour": "vor einer Stunde\nvor {{ n }} Stunden",
"date-day": "Gestern\nvor {{ n }} Tagen",

@ -1,29 +0,0 @@
define({
"postbox-text": "Γράψτε το σχόλιο εδώ (τουλάχιστον 3 χαρακτήρες)",
"postbox-author": "Όνομα (προαιρετικό)",
"postbox-email": "E-mail (προαιρετικό)",
"postbox-website": "Ιστοσελίδα (προαιρετικό)",
"postbox-preview": "Πρεμιέρα",
"postbox-edit": "Επεξεργασία",
"postbox-submit": "Υποβολή",
"num-comments": "Ένα σχόλιο\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": "πριν ένα λεπτό\nπριν {{ n }} λεπτά",
"date-hour": "πριν μία ώρα\nπριν {{ n }} ώρες",
"date-day": "Χτες\nπριν {{ n }} μέρες",
"date-week": "την προηγούμενη εβδομάδα\nπριν {{ n }} εβδομάδες",
"date-month": "τον προηγούμενο μήνα\nπριν {{ n }} μήνες",
"date-year": "πέρυσι\nπριν {{ n }} χρόνια"
});

@ -3,14 +3,13 @@ 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",
"postbox-submit": "submit",
"postbox-auth-required": "Authentication required to comment.",
"postbox-auth-sign-in": "Sign-in",
"postbox-auth-commenting-as": "Commenting as",
"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ŭ",

@ -1,29 +0,0 @@
define({
"postbox-text": "Escriba su comentario aquí (al menos 3 caracteres)",
"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",
"comment-reply": "Responder",
"comment-edit": "Editar",
"comment-save": "Guardar",
"comment-delete": "Eliminar",
"comment-confirm": "Confirmar",
"comment-close": "Cerrar",
"comment-cancel": "Cancelar",
"comment-deleted": "Comentario eliminado.",
"comment-queued": "Comentario en espera para moderación.",
"comment-anonymous": "Anónimo",
"comment-hidden": "{{ n }} Oculto(s)",
"date-now": "ahora",
"date-minute": "hace un minuto\nhace {{ n }} minutos",
"date-hour": "hace una hora\nhace {{ n }} horas",
"date-day": "ayer\nHace {{ n }} días",
"date-week": "la semana pasada\nhace {{ n }} semanas",
"date-month": "el mes pasado\nhace {{ n }} meses",
"date-year": "el año pasado\nhace {{ n }} años"
});

@ -1,32 +0,0 @@
define({
"postbox-text": "نظر خود را اینجا بنویسید (حداقل سه نویسه)",
"postbox-author": "اسم (اختیاری)",
"postbox-email": "ایمیل (اختیاری)",
"postbox-website": "سایت (اختیاری)",
"postbox-preview": "پیش‌نمایش",
"postbox-edit": "ویرایش",
"postbox-submit": "ارسال",
"num-comments": "یک نظر\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": "یک دقیقه پیش\n{{ n }} دقیقه پیش",
"date-hour": "یک ساعت پیش\n{{ n }} ساعت پیش",
"date-day": "دیروز\n{{ n }} روز پیش",
"date-week": "یک هفته پیش\n{{ n }} هفته پیش",
"date-month": "یک ماه پیش\n{{ n }} ماه پیش",
"date-year": "یک سال پیش\n{{ n }} سال پیش"
});

@ -1,32 +0,0 @@
define({
"postbox-text": "Kirjoita kommentti tähän (vähintään 3 merkkiä)",
"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",
"no-comments": "Ei vielä kommentteja",
"comment-reply": "Vastaa",
"comment-edit": "Muokkaa",
"comment-save": "Tallenna",
"comment-delete": "Poista",
"comment-confirm": "Vahvista",
"comment-close": "Sulje",
"comment-cancel": "Peru",
"comment-deleted": "Kommentti on poistettu.",
"comment-queued": "Kommentti on laitettu jonoon odottamaan moderointia.",
"comment-anonymous": "Nimetön",
"comment-hidden": "{{ n }} piilotettua",
"date-now": "hetki sitten",
"date-minute": "minuutti sitten\n{{ n }} minuuttia sitten",
"date-hour": "tunti sitten\n{{ n }} tuntia sitten",
"date-day": "eilen\n{{ n }} päivää sitten",
"date-week": "viime viikolla\n{{ n }} viikkoa sitten",
"date-month": "viime kuussa\n{{ n }} kuukautta sitten",
"date-year": "viime vuonna\n{{ n }} vuotta sitten"
});

@ -3,13 +3,14 @@ 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",
"postbox-auth-required": "Connexion nécessaire pour commenter.",
"postbox-auth-sign-in": "Se connecter",
"postbox-auth-commenting-as": "Commenter en tant que",
"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 +22,8 @@ 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",

@ -1,29 +0,0 @@
define({
"postbox-text": "Napiši komentar ovdje (najmanje 3 znaka)",
"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",
"comment-reply": "Odgovori",
"comment-edit": "Uredi",
"comment-save": "Spremi",
"comment-delete": "Obriši",
"comment-confirm": "Potvrdi",
"comment-close": "Zatvori",
"comment-cancel": "Odustani",
"comment-deleted": "Komentar obrisan",
"comment-queued": "Komentar u redu za provjeru.",
"comment-anonymous": "Anonimno",
"comment-hidden": "{{ n }} Skrivenih",
"date-now": "upravo",
"date-minute": "prije minutu\nprije {{ n }} minuta",
"date-hour": "prije sat vremena\nprije {{ n }} sati",
"date-day": "jučer\nprije {{ n }} dana",
"date-week": "prošli tjedan\nprije {{ n }} tjedana",
"date-month": "prošli mjesec\nprije {{ n }} mjeseci",
"date-year": "prošle godine\nprije {{ n }} godina"
});

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

@ -2,9 +2,6 @@ define({
"postbox-text": "Scrivi un commento qui (minimo 3 caratteri)",
"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",
@ -18,7 +15,6 @@ define({
"comment-deleted": "Commento eliminato.",
"comment-queued": "Commento in coda per moderazione.",
"comment-anonymous": "Anonimo",
"comment-hidden": "{{ n }} Nascosto",
"date-now": "poco fa",
"date-minute": "un minuto fa\n{{ n }} minuti fa",
"date-hour": "un ora fa\n{{ n }} ore fa",

@ -1,29 +0,0 @@
define({
"postbox-text": "Typ reactie hier (minstens 3 karakters)",
"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",
"comment-reply": "Beantwoorden",
"comment-edit": "Bewerken",
"comment-save": "Opslaan",
"comment-delete": "Verwijderen",
"comment-confirm": "Bevestigen",
"comment-close": "Sluiten",
"comment-cancel": "Annuleren",
"comment-deleted": "Reactie verwijderd.",
"comment-queued": "Reactie staat in de wachtrij voor goedkeuring.",
"comment-anonymous": "Anoniem",
"comment-hidden": "{{ n }} verborgen",
"date-now": "zojuist",
"date-minute": "een minuut geleden\n{{ n }} minuten geleden",
"date-hour": "een uur geleden\n{{ n }} uur geleden",
"date-day": "gisteren\n{{ n }} dagen geleden",
"date-week": "vorige week\n{{ n }} weken geleden",
"date-month": "vorige maand\n{{ n }} maanden geleden",
"date-year": "vorig jaar\n{{ n }} jaar geleden"
});

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

@ -1,34 +0,0 @@
define({
"postbox-text": "Tutaj wpisz komentarz (co najmniej 3 znaki)",
"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",
"comment-reply": "Odpowiedz",
"comment-edit": "Edytuj",
"comment-save": "Zapisz",
"comment-delete": "Usuń",
"comment-confirm": "Potwierdź",
"comment-close": "Zamknij",
"comment-cancel": "Anuluj",
"comment-deleted": "Komentarz usunięty.",
"comment-queued": "Komentarz w kolejce do moderacji.",
"comment-anonymous": "Anonim",
"comment-hidden": "{{ n }} ukryty\n{{ n }} ukryte\n{{ 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-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"
});

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

@ -1,30 +1,25 @@
define({
"postbox-text": "Оставить комментарий (минимум 3 символа)",
"postbox-text": "Комментировать здесь (миниум 3 символа)",
"postbox-author": "Имя (необязательно)",
"postbox-email": "Email (необязательно)",
"postbox-website": "Сайт (необязательно)",
"postbox-preview": "Предпросмотр",
"postbox-edit": "Правка",
"postbox-submit": "Отправить",
"postbox-notification": "Подписаться на уведомление об ответах",
"num-comments": "{{ n }} комментарий\n{{ n }} комментария\n{{ n }} комментариев",
"no-comments": "Пока нет комментариев",
"num-comments": "1 Комментарий\n{{ n }} Комментарии",
"no-comments": "Нет Комментарев",
"comment-reply": "Ответить",
"comment-edit": "Правка",
"comment-save": "Сохранить",
"comment-delete": "Удалить",
"comment-confirm": "Подтвердить удаление",
"comment-confirm": "Подтвердить",
"comment-close": "Закрыть",
"comment-cancel": "Отменить",
"comment-deleted": "Комментарий удалён",
"comment-queued": "Комментарий будет проверен модератором",
"comment-anonymous": "Аноним",
"comment-hidden": "Скрыт {{ n }} комментарий\nСкрыто {{ n }} комментария\nСкрыто {{ n }} комментариев",
"date-now": "Только что",
"date-minute": "{{ n }} минуту назад\n{{ n }} минуты назад\n{{ n }} минут назад",
"date-hour": "{{ n }} час назад\n{{ n }} часа назад\n{{ n }} часов назад",
"date-day": "{{ n }} день назад\n{{ n }} дня назад\n{{ n }} дней назад",
"date-week": "{{ n }} неделю назад\n{{ n }} недели назад\n{{ n }} недель назад",
"date-month": "{{ n }} месяц назад\n{{ n }} месяца назад\n{{ n }} месяцев назад",
"date-year": "{{ n }} год назад\n{{ n }} года назад\n{{ n }} лет назад"
"comment-deleted": "Удалить комментарий",
"comment-queued": "Комментарий должен быть разблокирован",
"comment-anonymous": "Анонимный",
"date-now": "Сейчас",
"date-minute": "Минут назад\n{{ n }} минут",
"date-hour": "Час назад\n{{ n }} часов",
"date-day": "Вчера\n{{ n }} дней",
"date-week": "на прошлой недели\n{{ n }} недель",
"date-month": "в прошоим месяце\n{{ n }} месяцов",
"date-year": "в прошлом году\n{{ n }} года\n{{ n }} лет"
});

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

@ -1,29 +0,0 @@
define({
"postbox-text": "Skriv din kommentar här (minst 3 tecken)",
"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",
"comment-reply": "Svara",
"comment-edit": "Redigera",
"comment-save": "Spara",
"comment-delete": "Radera",
"comment-confirm": "Bekräfta",
"comment-close": "Stäng",
"comment-cancel": "Avbryt",
"comment-deleted": "Kommentar raderad.",
"comment-queued": "Kommentaren inväntar granskning.",
"comment-anonymous": "Anonym",
"comment-hidden": "{{ n }} Gömd",
"date-now": "just nu",
"date-minute": "en minut sedan\n{{ n }} minuter sedan",
"date-hour": "en timme sedan\n{{ n }} timmar sedan",
"date-day": "igår\n{{ n }} dagar sedan",
"date-week": "förra veckan\n{{ n }} veckor sedan",
"date-month": "förra månaden\n{{ n }} månader sedan",
"date-year": "förra året\n{{ n }} år sedan"
});

@ -1,32 +0,0 @@
define({
"postbox-text": "Nhập bình luận tại đây (tối thiểu 3 ký tự)",
"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",
"no-comments": "Chưa có bình luận nào",
"comment-reply": "Trả lời",
"comment-edit": "Sửa",
"comment-save": "Lưu",
"comment-delete": "Xóa",
"comment-confirm": "Xác nhận",
"comment-close": "Đóng",
"comment-cancel": "Hủy",
"comment-deleted": "Đã xóa bình luận.",
"comment-queued": "Bình luận đang chờ duyệt",
"comment-anonymous": "Nặc danh",
"comment-hidden": "{{ n }} đã ẩn",
"date-now": "vừa mới",
"date-minute": "một phút trước\n{{ n }} phút trước",
"date-hour": "một giờ trước\n{{ n }} giờ trước",
"date-day": "Hôm qua\n{{ n }} ngày trước",
"date-week": "Tuần qua\n{{ n }} tuần trước",
"date-month": "Tháng trước\n{{ n }} tháng trước",
"date-year": "Năm trước\n{{ n }} năm trước"
});

@ -1,33 +0,0 @@
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 }} 年前"
});

@ -1,33 +0,0 @@
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 }} 年前"
});

@ -7,13 +7,21 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
var Postbox = function(parent) {
var localStorage = utils.localStorageImpl,
el = $.htmlify(jade.render("postbox", {
"author": JSON.parse(localStorage.getItem("author")),
"email": JSON.parse(localStorage.getItem("email")),
"website": JSON.parse(localStorage.getItem("website")),
"preview": ''
}));
if (config['auth'] === true) {
var authCookie = utils.cookie('auth');
if (! authCookie) {
jade.set("cookie", {'valid': false});
} else {
var authData = lib.itsdangerous(authCookie)
if (! authData) {
jade.set("cookie", {'valid': false});
} else {
jade.set("cookie", {'valid': true, 'data': authData});
}
}
}
var el = $.htmlify(jade.render("postbox"));
// callback on success (e.g. to toggle the reply button)
el.onsuccess = function() {};
@ -25,84 +33,30 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$(".textarea", this).focus();
return false;
}
if (config["require-email"] &&
$("[name='email']", this).value.length <= 0)
{
$("[name='email']", this).focus();
return false;
}
if (config["require-author"] &&
$("[name='author']", this).value.length <= 0)
{
$("[name='author']", this).focus();
return false;
}
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",
$("[name='email']", el).getAttribute("placeholder").replace(/ \(.*\)/, ""));
}
// author is not optional if this config parameter is set
if (config["require-author"]) {
$("[name='author']", el).placeholder =
$("[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();
var submit = $("[type=submit]", el)
if (submit === null) {
return el;
}
submit.on("click", function() {
if (! el.validate()) {
return;
}
var author = $("[name=author]", el).value || null,
email = $("[name=email]", el).value || null,
website = $("[name=website]", el).value || null;
localStorage.setItem("author", JSON.stringify(author));
localStorage.setItem("email", JSON.stringify(email));
localStorage.setItem("website", JSON.stringify(website));
api.create($("#isso-thread").getAttribute("data-isso-id"), {
author: author, email: email, website: website,
author: $("[name=author]", el).value || null,
email: $("[name=email]", el).value || null,
website: $("[name=website]", el).value || null,
text: utils.text($(".textarea", el).innerHTML),
parent: parent || null,
title: $("#isso-thread").getAttribute("data-title") || null,
notification: $("[name=notification]", el).checked() ? 1 : 0,
parent: parent || null
}).then(function(comment) {
$("[name=author]", el).value = "";
$("[name=email]", el).value = "";
$("[name=website]", el).value = "";
$(".textarea", el).innerHTML = "";
$(".textarea", el).blur();
insert(comment, true);
@ -165,7 +119,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
// update datetime every 60 seconds
var refresh = function() {
$(".permalink > time", el).textContent = utils.ago(
$(".permalink > date", el).textContent = utils.ago(
globals.offset.localTime(), new Date(parseInt(comment.created, 10) * 1000));
setTimeout(refresh, 60*1000);
};
@ -208,58 +162,36 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
}
);
if (config.vote) {
var voteLevels = config['vote-levels'];
if (typeof voteLevels === 'string') {
// Eg. -5,5,15
voteLevels = voteLevels.split(',');
}
// update vote counter
var votes = function (value) {
var span = $("span.votes", footer);
if (span === null) {
footer.prepend($.new("span.votes", value));
// update vote counter, but hide if votes sum to 0
var votes = function(value) {
var span = $("span.votes", footer);
if (span === null && value !== 0) {
footer.prepend($.new("span.votes", value));
} else {
if (value === 0) {
span.remove();
} else {
span.textContent = value;
}
if (value) {
el.classList.remove('isso-no-votes');
} else {
el.classList.add('isso-no-votes');
}
if (voteLevels) {
var before = true;
for (var index = 0; index <= voteLevels.length; index++) {
if (before && (index >= voteLevels.length || value < voteLevels[index])) {
el.classList.add('isso-vote-level-' + index);
before = false;
} else {
el.classList.remove('isso-vote-level-' + index);
}
}
}
};
}
};
$("a.upvote", footer).on("click", function () {
api.like(comment.id).then(function (rv) {
votes(rv.likes - rv.dislikes);
});
$("a.upvote", footer).on("click", function() {
api.like(comment.id).then(function(rv) {
votes(rv.likes - rv.dislikes);
});
});
$("a.downvote", footer).on("click", function () {
api.dislike(comment.id).then(function (rv) {
votes(rv.likes - rv.dislikes);
});
$("a.downvote", footer).on("click", function() {
api.dislike(comment.id).then(function(rv) {
votes(rv.likes - rv.dislikes);
});
votes(comment.likes - comment.dislikes);
}
});
$("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 = $(".avatar", el, false)[0];
edit.textContent = i18n.translate("comment-save");
edit.insertAfter($.new("a.cancel", i18n.translate("comment-cancel"))).on("click", function() {
@ -287,7 +219,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 = $(".avatar", el, false)[0];
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);
@ -24,13 +21,6 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
load("comment-loader", tt_comment_loader);
set("bool", function(arg) { return arg ? true : false; });
set("humanize", function(date) {
if (typeof date !== "object") {
date = new Date(parseInt(date, 10) * 1000);
}
return date.toString();
});
set("datetime", function(date) {
if (typeof date !== "object") {
date = new Date(parseInt(date, 10) * 1000);
@ -40,11 +30,7 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
date.getUTCFullYear(),
utils.pad(date.getUTCMonth(), 2),
utils.pad(date.getUTCDay(), 2)
].join("-") + "T" + [
utils.pad(date.getUTCHours(), 2),
utils.pad(date.getUTCMinutes(), 2),
utils.pad(date.getUTCSeconds(), 2)
].join(":") + "Z";
].join("-");
});
return {

@ -1,6 +1,7 @@
define(function (require) {
return {
editorify: require("app/lib/editor"),
identicons: require("app/lib/identicons")
identicons: require("app/lib/identicons"),
itsdangerous: require("app/lib/itsdangerous")
};
});

@ -3,7 +3,6 @@ define(["app/dom", "app/i18n"], function($, i18n) {
"use strict";
return function(el) {
el = $.htmlify(el);
el.setAttribute("contentEditable", true);
el.on("focus", function() {
@ -22,4 +21,5 @@ define(["app/dom", "app/i18n"], function($, i18n) {
return el;
};
});
});

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

@ -0,0 +1,47 @@
define(function() {
"use strict";
// 2011/01/01 in UTC
var epoch = 1293840000;
var decode = function(str) {
return atob(str + new Array((((- str.length) % 4 + 4) % 4) + 1).join("="));
};
var timestamp = function(str) {
var bytes = [];
for (var i = 0; i < str.length; i++) {
bytes.push(str.charCodeAt(i));
}
var a = 0;
for (var j = 0; j < bytes.length; j++) {
a = a << 8 | +bytes[j];
}
return a + epoch;
};
/*
* Load data signed with itsdangerous' URLSafeTimedSerializer.
*
* If no signature was found or the payload has been expired, return
* `null`. Otherwise, return unserialized datastructure.
*/
return function(val, max_age) {
var _ = val.split(".", 3),
payload = _[0], ts = _[1], signature = _[2];
if (typeof signature === "undefined") {
return null;
}
var age = (new Date()).getTime() / 1000 - timestamp(decode(ts));
if (typeof max_age !== "undefined" && age > max_age) {
return null;
}
return JSON.parse(decode(payload));
};
});

@ -0,0 +1,50 @@
define(["vendor/markup", "app/config", "app/i18n", "app/text/svg"], function(Mark, config, i18n, svg) {
"use strict";
// circumvent https://github.com/adammark/Markup.js/issues/22
function merge(obj) {
var result = {};
for (var prefix in obj) {
for (var attrname in obj[prefix]) {
result[prefix + "-" + attrname] = obj[prefix][attrname];
}
}
return result;
}
Mark.delimiter = ":";
Mark.includes = merge({
conf: config,
i18n: i18n[i18n.lang],
svg: svg
});
Mark.pipes.datetime = function(date) {
if (typeof date !== "object") {
date = new Date(parseInt(date, 10) * 1000);
}
return [date.getUTCFullYear(), pad(date.getUTCMonth(), 2), pad(date.getUTCDay(), 2)].join("-");
};
Mark.pipes.substract = function(a, b) {
return parseInt(a, 10) - parseInt(b, 10);
};
var strip = function(string) {
// allow whitespace between Markup.js delimiters such as
// {{ var | pipe : arg }} instead of {{var|pipe:arg}}
return string.replace(/\{\{\s*(.+?)\s*\}\}/g, function(match, val) {
return ("{{" + val + "}}").replace(/\s*\|\s*/g, "|")
.replace(/\s*\:\s*/g, ":");
});
};
return {
up: function(template, context) {
return Mark.up(strip(template), context);
}
};
});

@ -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}')
@ -13,9 +10,9 @@ div(class='isso-comment' id='isso-#{comment.id}')
else
span(class='author')
= bool(comment.author) ? comment.author : i18n('comment-anonymous')
span(class="spacer") &bull;
span(class="spacer")
a(class='permalink' href='#isso-#{comment.id}')
time(title='#{humanize(comment.created)}' datetime='#{datetime(comment.created)}')
date(datetime='#{datetime(comment.created)}')
span(class='note')
= comment.mode == 2 ? i18n('comment-queued') : comment.mode == 4 ? i18n('comment-deleted') : ''
@ -26,12 +23,13 @@ div(class='isso-comment' id='isso-#{comment.id}')
!= comment.text
div(class='isso-comment-footer')
if conf.vote
a(class='upvote' href='#')
!= svg['arrow-up']
span(class='spacer') |
a(class='downvote' href='#')
!= svg['arrow-down']
if comment.likes - comment.dislikes != 0
span(class='votes') #{comment.likes - comment.dislikes}
a(class='upvote' href='#')
i!= svg['arrow-up']
span(class='spacer') |
a(class='downvote' href='#')
i!= svg['arrow-down']
a(class='reply' href='#') #{i18n('comment-reply')}
a(class='edit' href='#') #{i18n('comment-edit')}
a(class='delete' href='#') #{i18n('comment-delete')}

@ -1,31 +1,25 @@
div(class='isso-postbox')
mixin form(style)
div(class='form-wrapper')
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')
value=author !== null ? '#{author}' : '')
p(class='input-wrapper')
input(type='email' name='email' placeholder=i18n('postbox-email')
value=email != null ? '#{email}' : '')
p(class='input-wrapper')
input(type='text' name='website' placeholder=i18n('postbox-website')
value=website != null ? '#{website}' : '')
p(class='input-wrapper' style=style)
input(type='text' name='author' placeholder=i18n('postbox-author'))
p(class='input-wrapper' style=style)
input(type='email' name='email' placeholder=i18n('postbox-email'))
p(class='input-wrapper' style=style)
input(type='text' name='website' placeholder=i18n('postbox-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')
div(class='isso-postbox')
if conf.auth
if cookie.valid
p=i18n('postbox-auth-commenting-as') + ' ' + cookie.data.username
+form('visibility: hidden;')
else
p=i18n('postbox-auth-required')
a(href='#{conf["auth-sign-in-url"]}') #{i18n('postbox-auth-sign-in')}
else
+form('')

@ -20,8 +20,8 @@ define(["app/i18n"], function(i18n) {
secs = 0;
}
var mins = Math.floor(secs / 60), hours = Math.floor(mins / 60),
days = Math.floor(hours / 24);
var mins = Math.ceil(secs / 60), hours = Math.ceil(mins / 60),
days = Math.ceil(hours / 24);
return secs <= 45 && i18n.translate("date-now") ||
secs <= 90 && i18n.pluralize("date-minute", 1) ||
@ -31,11 +31,11 @@ define(["app/i18n"], function(i18n) {
hours <= 36 && i18n.pluralize("date-day", 1) ||
days <= 5 && i18n.pluralize("date-day", days) ||
days <= 8 && i18n.pluralize("date-week", 1) ||
days <= 21 && i18n.pluralize("date-week", Math.floor(days / 7)) ||
days <= 21 && i18n.pluralize("date-week", Math.ceil(days / 7)) ||
days <= 45 && i18n.pluralize("date-month", 1) ||
days <= 345 && i18n.pluralize("date-month", Math.floor(days / 30)) ||
days <= 345 && i18n.pluralize("date-month", Math.ceil(days / 30)) ||
days <= 547 && i18n.pluralize("date-year", 1) ||
i18n.pluralize("date-year", Math.floor(days / 365.25));
i18n.pluralize("date-year", Math.ceil(days / 365.25));
};
var HTMLEntity = {
@ -57,45 +57,20 @@ define(["app/i18n"], function(i18n) {
var _ = document.createElement("div");
_.innerHTML = html.replace(/<div><br><\/div>/gi, '<br>')
.replace(/<div>/gi,'<br>')
.replace(/<br>/gi, '\n')
.replace(/&nbsp;/gi, ' ');
.replace(/<br>/gi, '\n');
return _.textContent.trim();
};
var detext = function(text) {
text = escape(text);
return text.replace(/\n\n/gi, '<br><div><br></div>')
.replace(/\n/gi, '<br>');
return escape(text.replace(/\n\n/gi, '<br><div><br></div>')
.replace(/\n/gi, '<br>'));
};
// Safari private browsing mode supports localStorage, but throws QUOTA_EXCEEDED_ERR
var localStorageImpl;
try {
localStorage.setItem("x", "y");
localStorage.removeItem("x");
localStorageImpl = localStorage;
} catch (ex) {
localStorageImpl = (function(storage) {
return {
setItem: function(key, val) {
storage[key] = val;
},
getItem: function(key) {
return typeof(storage[key]) !== 'undefined' ? storage[key] : null;
},
removeItem: function(key) {
delete storage[key];
}
};
})({});
}
return {
cookie: cookie,
pad: pad,
ago: ago,
text: text,
detext: detext,
localStorageImpl: localStorageImpl
detext: detext
};
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save