Merge branch 'master' into feature/gravatar-support

This commit is contained in:
Benoît Latinier 2018-04-25 22:45:45 +02:00 committed by GitHub
commit 6da91d4ace
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 1899 additions and 425 deletions

View File

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

View File

@ -4,7 +4,18 @@ Changelog for Isso
0.10.7 (unreleased)
-------------------
- Nothing changed yet.
- Fix Chinese translation & typo in CJK
- Fix link in moderation mails if isso is setup on a sub-url (e.g. domain.tld/comments/)
- Add Danish translation
- Add Hungarian translation
- Add Persian translation
- Add admin interface
- Add links highlighting in comments
- Add apidoc
- Add rc.d script for FreeBSD
- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable
- Some tests/travis/documentation improvements and fixes + pep8
- Improvement on german translation
0.10.6 (2016-09-22)

View File

@ -51,12 +51,40 @@ In chronological order:
* Added configuration to require email addresses (no validation)
* Fix Vagrantfile
* Benoît Latinier <benoit@latinier.fr>
* 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>
* 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
* [Your name or handle] <[email or website]>
* [Brief summary of your changes]

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# First, compile JS stuff
FROM node
WORKDIR /src/
COPY . .
RUN npm install -g requirejs uglify-js jade bower
RUN make init js
# Second, create virtualenv
FROM python:3-stretch
WORKDIR /src/
COPY --from=0 /src .
RUN apt-get -qqy update && apt-get -qqy install python3-dev sqlite3
RUN python3 -m venv /isso \
&& . /isso/bin/activate \
&& python setup.py install \
&& pip install gunicorn
# Third, create final repository
FROM python:3-slim-stretch
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"]
# 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

View File

@ -9,3 +9,9 @@ include isso/js/count.min.js
include isso/js/count.dev.js
include isso/defaults.ini
include isso/templates/admin.html
include isso/templates/login.html
include isso/css/admin.css
include isso/css/isso.css
include isso/img/isso.svg

View File

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

11
apidoc.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ 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"
@ -19,15 +20,16 @@ preferably in the script tag which embeds the JS:
data-isso-avatar-bg="#f0f0f0"
data-isso-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
data-isso-vote="true"
data-vote-levels=""
data-isso-vote-levels=""
data-isso-feed="false"
src="/prefix/js/embed.js"></script>
Furthermore you can override the automatic title detection inside
the embed tag, e.g.:
the embed tag, as well as the thread ID, e.g.:
.. code-block:: html
<section id="isso-thread" data-title="Foo!"></section>
<section id="isso-thread" data-title="Foo!" data-isso-id="/path/to/resource"></section>
data-isso
---------
@ -133,13 +135,9 @@ For example, the value `"-5,5"` will cause each `isso-comment` to be given one o
These classes can then be used to customize the appearance of comments (eg. put a star on popular comments)
data-isso-id
------------
data-isso-feed
--------------
Set a custom thread id, defaults to current URI. This attribute needs
to be used with the data-title attribute in order to work.
If you use a comment counter, add this attribute to the link tag, too.
.. code-block:: html
<section data-title="Yay!" data-isso-id="test.abc" id="isso-thread"></section>
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.

View File

@ -120,7 +120,7 @@ Enable moderation queue and handling of comments still in moderation queue
enabled
enable comment moderation queue. This option only affects new comments.
Comments in modertion queue are not visible to other users until you
Comments in moderation queue are not visible to other users until you
activate them.
purge-after
@ -320,6 +320,27 @@ 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
Appendum
--------

View File

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

View File

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

View File

@ -10,7 +10,7 @@ Debian/Ubuntu, Gentoo, Archlinux or Fedora, you can use
:local:
:depth: 1
.. _install-interludium:
.. _install-interludium:
Interludium: Python is not PHP
------------------------------
@ -39,10 +39,10 @@ package manager.
.. code-block:: sh
# for Debian/Ubuntu
~> sudo apt-get install python-setuptools python-virtualenv
~> sudo apt-get install python-setuptools python-virtualenv python-dev
# Fedora/Red Hat
~> sudo yum install python-setuptools python-virtualenv
~> sudo yum install python-setuptools python-virtualenv python-devel
The next steps should be done as regular user, not as root (although possible
but not recommended):
@ -79,7 +79,7 @@ Install from PyPi
Requirements
^^^^^^^^^^^^
- Python 2.6, 2.7 or 3.3+ (+ devel headers)
- Python 2.7 or 3.4+ (+ devel headers)
- SQLite 3.3.8 or later
- a working C compiler
@ -149,7 +149,18 @@ Prebuilt Packages
* Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr
repository. Built from Pypi, includes a systemctl unit script.
* Docker Image: https://registry.hub.docker.com/u/bl4n/isso/
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
-------------------
@ -157,7 +168,7 @@ 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.6, 2.7 or 3.3+ (+ devel headers)
- Python 2.7 or 3.4+ (+ devel headers)
- Virtualenv
- SQLite 3.3.8 or later
- a working C compiler

View File

@ -1,6 +1,12 @@
Troubleshooting
===============
For uberspace users
-------------------
Some uberspace users experienced problems with isso and they solved their
issue by adding `DirectoryIndex disabled` as the first line in the `.htaccess`
file for the domain the isso server is running on.
pkg_ressources.DistributionNotFound
-----------------------------------

View File

@ -35,7 +35,8 @@ 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
@ -87,7 +88,8 @@ class Isso(object):
self.conf = conf
self.db = db.SQLite3(conf.get('general', 'dbpath'), conf)
self.signer = URLSafeTimedSerializer(self.db.preferences.get("session-key"))
self.signer = URLSafeTimedSerializer(
self.db.preferences.get("session-key"))
self.markup = html.Markup(conf.section('markup'))
self.hasher = hash.new(conf.section("hash"))
@ -122,7 +124,8 @@ class Isso(object):
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)
@ -136,7 +139,8 @@ 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
@ -181,18 +185,19 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
if isso.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(isso.conf.getiter("general", "host")),
allowed=("Origin", "Referer", "Content-Type"),
exposed=("X-Set-Cookie", "Date")))
wrapper.extend([wsgi.SubURI, ProxyFix])
@ -207,9 +212,10 @@ 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")
metavar="/etc/isso.conf", help="set configuration file")
imprt = subparser.add_parser('import', help="import Disqus XML export")
imprt.add_argument("dump", metavar="FILE")
@ -220,10 +226,12 @@ def main():
imprt.add_argument("--empty-id", dest="empty_id", action="store_true",
help="workaround for weird Disqus XML exports, #135")
serve = subparser.add_parser("run", help="run server")
# run Isso as stand-alone server
subparser.add_parser("run", help="run server")
args = parser.parse_args()
conf = config.load(join(dist.location, dist.project_name, "defaults.ini"), args.conf)
conf = config.load(
join(dist.location, dist.project_name, "defaults.ini"), args.conf)
if args.command == "import":
conf.set("guard", "enabled", "off")

View File

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

View File

@ -17,11 +17,6 @@ from isso.compat import text_type as str
logger = logging.getLogger("isso")
# Python 2.6 compatibility
def total_seconds(td):
return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6
def timedelta(string):
"""
Parse :param string: into :class:`datetime.timedelta`, you can use any
@ -101,7 +96,7 @@ class IssoParser(ConfigParser):
try:
return int(delta.total_seconds())
except AttributeError:
return int(total_seconds(delta))
return int(delta.total_seconds())
def getlist(self, section, key):
return list(map(str.strip, self.get(section, key).split(',')))
@ -128,8 +123,8 @@ def new(options=None):
def load(default, user=None):
# return set of (section, option)
setify = lambda cp: set((section, option) for section in cp.sections()
for option in cp.options(section))
def setify(cp): return set((section, option) for section in cp.sections()
for option in cp.options(section))
parser = new()
parser.read(default)

View File

@ -122,7 +122,8 @@ class uWSGIMixin(Mixin):
self.cache = uWSGICache
timedelta = conf.getint("moderation", "purge-after")
purge = lambda signum: self.db.comments.purge(timedelta)
def purge(signum): return self.db.comments.purge(timedelta)
uwsgi.register_signal(1, "", purge)
uwsgi.add_timer(1, timedelta)

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

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

View File

@ -15,39 +15,42 @@
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: #AAA;
color: #757575;
}
.isso-comment {
#isso-root .isso-comment {
max-width: 68em;
padding-top: 0.95em;
margin: 0.95em auto;
}
.isso-comment:not(:first-of-type),
#isso-root .preview .isso-comment {
padding-top: 0;
margin: 0;
}
#isso-root .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-postbox > .avatar {
.isso-comment > div.avatar {
display: block;
float: left;
width: 7%;
margin: 3px 15px 0 0;
}
.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 {
.isso-comment > div.avatar > svg {
max-width: 48px;
max-height: 48px;
border: 1px solid rgba(0, 0, 0, 0.2);
@ -90,7 +93,8 @@
font-weight: bold;
color: #555;
}
.isso-comment > div.text-wrapper > .textarea-wrapper .textarea {
.isso-comment > div.text-wrapper > .textarea-wrapper .textarea,
.isso-comment > div.text-wrapper > .textarea-wrapper .preview {
margin-top: 0.2em;
}
.isso-comment > div.text-wrapper > div.text p {
@ -108,7 +112,8 @@
font-size: 130%;
font-weight: bold;
}
.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea {
.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea,
.isso-comment > div.text-wrapper > div.textarea-wrapper .preview {
width: 100%;
border: 1px solid #f0f0f0;
border-radius: 2px;
@ -119,10 +124,12 @@
color: gray !important;
clear: left;
}
.isso-feedlink,
.isso-comment > div.text-wrapper > .isso-comment-footer a {
font-weight: bold;
text-decoration: none;
}
.isso-feedlink:hover,
.isso-comment > div.text-wrapper > .isso-comment-footer a:hover {
color: #111111 !important;
text-shadow: #aaaaaa 0 0 1px !important;
@ -152,6 +159,7 @@
.isso-postbox {
max-width: 68em;
margin: 0 auto 2em;
clear: right;
}
.isso-postbox > .form-wrapper {
display: block;
@ -161,7 +169,8 @@
.isso-postbox > .form-wrapper > .auth-section .post-action {
display: block;
}
.isso-postbox > .form-wrapper .textarea {
.isso-postbox > .form-wrapper .textarea,
.isso-postbox > .form-wrapper .preview {
margin: 0 0 .3em;
padding: .4em .8em;
border-radius: 3px;
@ -191,7 +200,7 @@
.isso-postbox > .form-wrapper > .auth-section .post-action {
display: inline-block;
float: right;
margin: 0;
margin: 0 0 0 5px;
}
.isso-postbox > .form-wrapper > .auth-section .post-action > input {
padding: calc(.3em - 1px);
@ -209,6 +218,28 @@
.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
);
}
@media screen and (max-width:600px) {
.isso-postbox > .form-wrapper > .auth-section .input-wrapper {
display: block;
@ -218,9 +249,4 @@
.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;
}
}

View File

@ -98,10 +98,11 @@ class SQLite3:
# limit max. nesting level to 1
if self.version == 2:
first = lambda rv: list(map(operator.itemgetter(0), rv))
def first(rv): return list(map(operator.itemgetter(0), rv))
with sqlite3.connect(self.path) as con:
top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall())
top = first(con.execute(
"SELECT id FROM comments WHERE parent IS NULL").fetchall())
flattened = defaultdict(set)
for id in top:
@ -109,13 +110,15 @@ 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)

View File

@ -19,8 +19,11 @@ class Comments:
The tuple (tid, id) is unique and thus primary key.
"""
fields = ['tid', 'id', 'parent', 'created', 'modified', 'mode', 'remote_addr',
'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters']
fields = ['tid', 'id', 'parent', 'created', 'modified',
'mode', # status of the comment 1 = valid, 2 = pending,
# 4 = soft-deleted (cannot hard delete because of replies)
'remote_addr', 'text', 'author', 'email', 'website',
'likes', 'dislikes', 'voters']
def __init__(self, db):
@ -80,7 +83,7 @@ class Comments:
"""
self.db.execute([
'UPDATE comments SET',
','.join(key + '=' + '?' for key in data),
','.join(key + '=' + '?' for key in data),
'WHERE id=?;'],
list(data.values()) + [id])
@ -91,19 +94,79 @@ class Comments:
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()
rv = self.db.execute(
'SELECT * FROM comments WHERE id=?', (id, )).fetchone()
if rv:
return dict(zip(Comments.fields, rv))
return None
def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=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 = ['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]
@ -119,7 +182,8 @@ class Comments:
order_by = 'id'
sql.append('ORDER BY ')
sql.append(order_by)
sql.append(' ASC')
if not asc:
sql.append(' DESC')
if limit:
sql.append('LIMIT ?')
@ -155,7 +219,8 @@ class Comments:
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()
refs = self.db.execute(
'SELECT * FROM comments WHERE parent=?', (id, )).fetchone()
if refs is None:
self.db.execute('DELETE FROM comments WHERE id=?', (id, ))
@ -165,7 +230,8 @@ class Comments:
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.db.execute('UPDATE comments SET %s=? WHERE id=?' %
field, (None, id))
self._remove_stale()
return self.get(id)

View File

@ -24,7 +24,7 @@ class Preferences:
def get(self, key, default=None):
rv = self.db.execute(
'SELECT value FROM preferences WHERE key=?', (key, )).fetchone()
'SELECT value FROM preferences WHERE key=?', (key, )).fetchone()
if rv is None:
return default

View File

@ -50,7 +50,7 @@ class Guard:
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") == False:
elif self.conf.getboolean("reply-to-self") is False:
rv = self.db.execute([
'SELECT id FROM comments WHERE'
' remote_addr = ?',

View File

@ -26,5 +26,6 @@ class Threads(object):
return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone())
def new(self, uri, title):
self.db.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
self.db.execute(
"INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
return self[uri]

View File

@ -25,7 +25,8 @@ class Dispatcher(DispatcherMiddleware):
def __init__(self, *confs):
self.isso = {}
default = os.path.join(dist.location, dist.project_name, "defaults.ini")
default = os.path.join(
dist.location, dist.project_name, "defaults.ini")
for i, path in enumerate(confs):
conf = config.load(default, path)
@ -45,7 +46,8 @@ 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)

View File

@ -37,6 +37,12 @@ class SMTP(object):
self.isso = isso
self.conf = isso.conf.section("smtp")
gh = isso.conf.get("general", "host")
if type(gh) == str:
self.general_host = gh
#if gh is not a string then gh is a list
else:
self.general_host = gh[0]
# test SMTP connectivity
try:
@ -58,7 +64,8 @@ class SMTP(object):
uwsgi.spooler = spooler
def __enter__(self):
klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP)
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'))
@ -104,10 +111,11 @@ class SMTP(object):
rv.write("User's URL: %s\n" % comment["website"])
rv.write("IP address: %s\n" % comment["remote_addr"])
rv.write("Link to comment: %s\n" % (local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
rv.write("Link to comment: %s\n" %
(local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
rv.write("\n")
uri = local("host") + "/id/%i" % comment["id"]
uri = self.general_host + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
rv.write("---\n")
@ -173,7 +181,8 @@ 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)

2
isso/img/isso.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -191,6 +191,24 @@ 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,
@ -202,6 +220,8 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
fetch: fetch,
count: count,
like: like,
dislike: dislike
dislike: dislike,
feed: feed,
preview: preview
};
});

View File

@ -16,7 +16,8 @@ define(function() {
"avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6",
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
"vote": true,
"vote-levels": null
"vote-levels": null,
"feed": false
};
var js = document.getElementsByTagName("script");

View File

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

View File

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

View File

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

View File

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

32
isso/js/app/i18n/da.js Normal file
View File

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

View File

@ -1,11 +1,13 @@
define({
"postbox-text": "Kommentar hier eintippen (mindestens 3 Zeichen)",
"postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)",
"postbox-author": "Name (optional)",
"postbox-email": "Email (optional)",
"postbox-website": "Website (optional)",
"postbox-preview": "Vorschau",
"postbox-edit": "Bearbeiten",
"postbox-submit": "Abschicken",
"num-comments": "1 Kommentar\n{{ n }} Kommentare",
"no-comments": "Keine Kommentare bis jetzt",
"no-comments": "Bisher keine Kommentare",
"comment-reply": "Antworten",
"comment-edit": "Bearbeiten",
"comment-save": "Speichern",
@ -17,7 +19,7 @@ define({
"comment-queued": "Kommentar muss noch freigeschaltet werden.",
"comment-anonymous": "Anonym",
"comment-hidden": "{{ n }} versteckt",
"date-now": "eben jetzt",
"date-now": "eben gerade",
"date-minute": "vor einer Minute\nvor {{ n }} Minuten",
"date-hour": "vor einer Stunde\nvor {{ n }} Stunden",
"date-day": "Gestern\nvor {{ n }} Tagen",

View File

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

View File

@ -3,10 +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",
"num-comments": "One Comment\n{{ n }} Comments",
"no-comments": "No Comments Yet",
"atom-feed": "Atom feed",
"comment-reply": "Reply",
"comment-edit": "Edit",

View File

@ -3,6 +3,8 @@ 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ŭ",

View File

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

View File

@ -3,6 +3,8 @@ define({
"postbox-author": "اسم (اختیاری)",
"postbox-email": "ایمیل (اختیاری)",
"postbox-website": "سایت (اختیاری)",
"postbox-preview": "پیشنمایش",
"postbox-edit": "ویرایش",
"postbox-submit": "ارسال",
"num-comments": "یک نظر\n{{ n }} نظر",

View File

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

View File

@ -3,9 +3,12 @@ define({
"postbox-author": "Nom (optionnel)",
"postbox-email": "Courriel (optionnel)",
"postbox-website": "Site web (optionnel)",
"postbox-preview": "Aperçu",
"postbox-edit": "Éditer",
"postbox-submit": "Soumettre",
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
"no-comments": "Aucun commentaire pour l'instant",
"atom-feed": "Flux Atom",
"comment-reply": "Répondre",
"comment-edit": "Éditer",
"comment-save": "Enregistrer",

View File

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

29
isso/js/app/i18n/hu.js Normal file
View File

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

View File

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

View File

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

View File

@ -3,6 +3,8 @@ define({
"postbox-author": "Imię/nick (opcjonalnie)",
"postbox-email": "E-mail (opcjonalnie)",
"postbox-website": "Strona (opcjonalnie)",
"postbox-preview": "Visualizar",
"postbox-edit": "Edytuj",
"postbox-submit": "Wyślij",
"num-comments": "Jeden komentarz\n{{ n }} komentarzy",
"no-comments": "Jeszcze nie ma komentarzy",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
define({
"postbox-text": "在此輸入留言(至少3個字元",
"postbox-text": "在此輸入留言(至少 3 個字元)",
"postbox-author": "名稱 (非必填)",
"postbox-email": "電子信箱 (非必填)",
"postbox-website": "個人網站 (非必填)",
"postbox-preview": "預習",
"postbox-edit": "編輯",
"postbox-submit": "送出",
"num-comments": "1則留言\n{{ n }}則留言",
"num-comments": "1 則留言\n{{ n }} 則留言",
"no-comments": "尚無留言",
"comment-reply": "回覆",
@ -18,13 +20,13 @@ define({
"comment-deleted": "留言已刪",
"comment-queued": "留言待審",
"comment-anonymous": "匿名",
"comment-hidden": "{{ n }}則隱藏留言",
"comment-hidden": "{{ n }} 則隱藏留言",
"date-now": "剛剛",
"date-minute": "1分鐘前\n{{ n }}分鐘前",
"date-hour": "1小時前\n{{ n }}小時前",
"date-day": "昨天\n{{ n }}天前",
"date-week": "上週\n{{ n }}週前",
"date-month": "上個月\n{{ n }}個月前",
"date-year": "去年\n{{ n }}年前"
"date-minute": "1 分鐘前\n{{ n }} 分鐘前",
"date-hour": "1 小時前\n{{ n }} 小時前",
"date-day": "昨天\n{{ n }} 天前",
"date-week": "上週\n{{ n }} 週前",
"date-month": "上個月\n{{ n }} 個月前",
"date-year": "去年\n{{ n }} 年前"
});

View File

@ -11,7 +11,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
el = $.htmlify(jade.render("postbox", {
"author": JSON.parse(localStorage.getItem("author")),
"email": JSON.parse(localStorage.getItem("email")),
"website": JSON.parse(localStorage.getItem("website"))
"website": JSON.parse(localStorage.getItem("website")),
"preview": ''
}));
// callback on success (e.g. to toggle the reply button)
@ -41,8 +42,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
// email is not optional if this config parameter is set
if (config["require-email"]) {
$("[name='email']", el).placeholder =
$("[name='email']", el).placeholder.replace(/ \(.*\)/, "");
$("[name='email']", el).setAttribute("placeholder",
$("[name='email']", el).getAttribute("placeholder").replace(/ \(.*\)/, ""));
}
// author is not optional if this config parameter is set
@ -51,9 +52,27 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("[name='author']", el).placeholder.replace(/ \(.*\)/, "");
}
// preview function
$("[name='preview']", el).on("click", function() {
api.preview(utils.text($(".textarea", el).innerHTML)).then(
function(html) {
$(".preview .text", el).innerHTML = html;
el.classList.add('preview-mode');
});
});
// edit function
var edit = function() {
$(".preview .text", el).innerHTML = '';
el.classList.remove('preview-mode');
};
$("[name='edit']", el).on("click", edit);
$(".preview", el).on("click", edit);
// submit form, initialize optional fields with `null` and reset form.
// If replied to a comment, remove form completely.
$("[type=submit]", el).on("click", function() {
edit();
if (! el.validate()) {
return;
}

View File

@ -7,6 +7,9 @@ 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);

View File

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

View File

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

View File

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

View File

@ -27,6 +27,13 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
return console.log("abort, #isso-thread is missing");
}
if (config["feed"]) {
var feedLink = $.new('a', i18n.translate('atom-feed'));
var feedLinkWrapper = $.new('span.isso-feedlink');
feedLink.href = api.feed($("#isso-thread").getAttribute("data-isso-id"));
feedLinkWrapper.append(feedLink);
$("#isso-thread").append(feedLinkWrapper);
}
$("#isso-thread").append($.new('h4'));
$("#isso-thread").append(new isso.Postbox(null));
$("#isso-thread").append('<div id="isso-root"></div>');

View File

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

View File

@ -30,6 +30,7 @@ from xml.etree import ElementTree
logger = logging.getLogger("isso")
def strip(val):
if isinstance(val, string_types):
return val.strip()
@ -82,7 +83,8 @@ class Disqus(object):
remap = dict()
if path not in self.db.threads:
self.db.threads.new(path, thread.find(Disqus.ns + 'title').text.strip())
self.db.threads.new(path, thread.find(
Disqus.ns + 'title').text.strip())
for item in sorted(posts, key=lambda k: k['created']):
@ -112,9 +114,11 @@ class Disqus(object):
}
if post.find(Disqus.ns + 'parent') is not None:
item['dsq:parent'] = post.find(Disqus.ns + 'parent').attrib.get(Disqus.internals + 'id')
item['dsq:parent'] = post.find(
Disqus.ns + 'parent').attrib.get(Disqus.internals + 'id')
res[post.find('%sthread' % Disqus.ns).attrib.get(Disqus.internals + 'id')].append(item)
res[post.find('%sthread' % Disqus.ns).attrib.get(
Disqus.internals + 'id')].append(item)
progress = Progress(len(tree.findall(Disqus.ns + 'thread')))
for i, thread in enumerate(tree.findall(Disqus.ns + 'thread')):
@ -135,7 +139,8 @@ class Disqus(object):
progress.finish("{0} threads, {1} comments".format(
len(self.threads), len(self.comments)))
orphans = set(map(lambda e: e.attrib.get(Disqus.internals + "id"), tree.findall(Disqus.ns + "post"))) - self.comments
orphans = set(map(lambda e: e.attrib.get(Disqus.internals + "id"),
tree.findall(Disqus.ns + "post"))) - self.comments
if orphans and not self.threads:
print("Isso couldn't import any thread, try again with --empty-id")
elif orphans:
@ -258,22 +263,22 @@ def autodetect(peek):
def dispatch(type, db, dump, empty_id=False):
if db.execute("SELECT * FROM comments").fetchone():
if input("Isso DB is not empty! Continue? [y/N]: ") not in ("y", "Y"):
raise SystemExit("Abort.")
if db.execute("SELECT * FROM comments").fetchone():
if input("Isso DB is not empty! Continue? [y/N]: ") not in ("y", "Y"):
raise SystemExit("Abort.")
if type == "disqus":
cls = Disqus
elif type == "wordpress":
cls = WordPress
else:
with io.open(dump, encoding="utf-8") as fp:
cls = autodetect(fp.read(io.DEFAULT_BUFFER_SIZE))
if type == "disqus":
cls = Disqus
elif type == "wordpress":
cls = WordPress
else:
with io.open(dump, encoding="utf-8") as fp:
cls = autodetect(fp.read(io.DEFAULT_BUFFER_SIZE))
if cls is None:
raise SystemExit("Unknown format, abort.")
if cls is None:
raise SystemExit("Unknown format, abort.")
if cls is Disqus:
cls = functools.partial(cls, empty_id=empty_id)
if cls is Disqus:
cls = functools.partial(cls, empty_id=empty_id)
cls(db, dump).migrate()
cls(db, dump).migrate()

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

@ -0,0 +1,253 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="/css/isso.css" rel="stylesheet">
<link type="text/css" href="/css/admin.css" rel="stylesheet">
</head>
<body>
<script type="text/javascript">
function ajax(req) {
var r = new XMLHttpRequest();
r.open(req.method, req.url, true);
r.onreadystatechange = function () {
if (r.readyState != 4 || r.status != 200) {
if (req.failure) {
req.failure();
}
return;
}
req.success(r.responseText);
};
r.send(req.data);
}
function fade(element) {
var op = 1; // initial opacity
var timer = setInterval(function () {
if (op <= 0.1){
clearInterval(timer);
element.style.display = 'none';
}
element.style.opacity = op;
element.style.filter = 'alpha(opacity=' + op * 100 + ")";
op -= op * 0.1;
}, 10);
}
function moderate(com_id, hash, action) {
ajax({method: "POST",
url: "/id/" + com_id + "/" + action + "/" + hash,
success: function(){
fade(document.getElementById("isso-" + com_id));
}});
}
function edit(com_id, hash, author, email, website, comment) {
ajax({method: "POST",
url: "/id/" + com_id + "/edit/" + hash,
data: JSON.stringify({text: comment,
author: author,
email: email,
website: website}),
success: function(ret){
console.log("edit successed: ", ret);// TODO display some pretty stuff & update msg
},
error: function(ret){
console.log("Error: ", ret); // TODO flash msg/notif
}});
}
function validate_com(com_id, hash) {
moderate(com_id, hash, "activate");
}
function delete_com(com_id, hash) {
moderate(com_id, hash, "delete");
}
function unset_editable(elt_id) {
var elt = document.getElementById(elt_id);
if (elt) {
elt.contentEditable = false;
elt.classList.remove("editable");
}
}
function set_editable(elt_id) {
var elt = document.getElementById(elt_id);
if (elt) {
elt.contentEditable = true;
elt.classList.add("editable");
}
}
function start_edit(com_id) {
var editable_elements = ['isso-author-' + com_id,
'isso-email-' + com_id,
'isso-website-' + com_id,
'isso-text-' + com_id];
for (var idx=0; idx <= editable_elements.length; idx++) {
set_editable(editable_elements[idx]);
}
document.getElementById('edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('stop-edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('send-edit-btn-' + com_id).classList.toggle('hidden');
}
function stop_edit(com_id) {
var editable_elements = ['isso-author-' + com_id,
'isso-email-' + com_id,
'isso-website-' + com_id,
'isso-text-' + com_id];
for (var idx=0; idx <= editable_elements.length; idx++) {
unset_editable(editable_elements[idx]);
}
document.getElementById('edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('stop-edit-btn-' + com_id).classList.toggle('hidden');
document.getElementById('send-edit-btn-' + com_id).classList.toggle('hidden');
}
function send_edit(com_id, hash) {
var author = document.getElementById('isso-author-' + com_id).textContent;
var email = document.getElementById('isso-email-' + com_id).textContent;
var website = document.getElementById('isso-website-' + com_id).textContent;
var comment = document.getElementById('isso-text-' + com_id).textContent;
edit(com_id, hash, author, email, website, comment);
stop_edit(com_id);
}
</script>
<div class="wrapper">
<div class="header">
<header>
<img class="logo" src="/img/isso.svg" alt="Wynaut by @veekun"/>
<div class="title">
<a href="./">
<h1>Isso</h1>
<h2>Administration</h2>
</a>
</div>
</header>
</div>
<div class="outer">
<div class="filters">
<div class="mode">
<a href="?mode=1&page={{page}}&order_by={{order_by}}">
<span class="label label-valid {% if mode == 1 %}active{% endif %}">
Valid ({{counts.get(1, 0)}})
</span>
</a>
<a href="?mode=2&page={{page}}&order_by={{order_by}}">
<span class="label label-pending {% if mode == 2 %}active{% endif %}">
Pending ({{counts.get(2, 0)}})
</span>
</a>
<a href="?mode=4&page={{page}}&order_by={{order_by}}">
<span class="label label-staled {% if mode == 4 %}active{% endif %}">
Staled ({{counts.get(4, 0)}})
</span>
</a>
</div>
<div class="group">
Group by thread: <input type="checkbox" {% if order_by == "tid" %}checked{% endif %} onClick="javascript:window.location='?mode={{mode}}&page={{page}}&order_by={% if order_by == "tid" %}id{% else %}tid{% endif %}';" />
</div>
<div class="pagination">
Pages:
{% if page > 0 %}
<a href="?mode={{mode}}&page={{page - 1}}">
«
</a>
{% endif %}
<input type="text" size="1" name="page" value="{{page}}" />
{% if page < max_page %}
<a href="?mode={{mode}}&page={{page + 1}}">
»
</a>
{% endif %}
/ {{ max_page }}
</div>
</div>
<div class="filters order">
Order:
{% for order in ['id', 'created', 'modified', 'likes', 'dislikes'] %}
<a href="?mode={{mode}}&page={{page}}&order_by={{order}}&asc={{1 - asc}}">
<span class="label label-valid {% if order == order_by %}active{% endif %}">
{{ order }}
{% if order == order_by %}
{% if asc %} ↑ {% else %} ↓ {% endif %}
{% else %}
{% endif %}
</span>
</a>
{% endfor %}
</div>
</div>
<main>
{% set thread_id = "no_id" %}
{% for comment in comments %}
{% if order_by == "tid" %}
{% if thread_id != comment.tid %}
{% set thread_id = comment.tid %}
<h2 class="thread-title">{{comment.title}} (<a href="{{comment.uri}}">{{comment.uri}}</a>)</h2>
{% endif %}
{% endif %}
<div class='isso-comment' id='isso-{{comment.id}}'>
{% if conf.avatar %}
<div class='avatar'>
svg(data-hash='#{{comment.hash}}')
</div>
{% endif %}
<div class='text-wrapper'>
<div class='isso-comment-header' role='meta'>
{% if order_by != "tid" %}
<div>Thread: {{comment.title}} (<a href="{{comment.uri}}">{{comment.uri}}</a>)</div><br />
{% endif %}
{% if comment.author %}
<span class='author' id="isso-author-{{comment.id}}">{{comment.author}}</span>
{% else %}
<span class='author' id="isso-author-{{comment.id}}">Anonymous</span>
{% endif %}
{% if comment.email %}
(<span id="isso-email-{{comment.id}}">{{comment.email}}</span> <a href="mailto:{{comment.email}}" rel='nofollow' class='email'>mailto</a>)
{% else %}
<span id="isso-email-{{comment.id}}"></span>
{% endif %}
{% if comment.website %}
(<span id="isso-website-{{comment.id}}">{{comment.website}}</span> <a href="{{comment.website}}" rel='nofollow' class='website'>open</a>)
{% else %}
<span id="isso-website-{{comment.id}}"></span>
{% endif %}
<span class="spacer"> &bull;</span>
<time>{{comment.created | datetimeformat}}</time>
<span class='note'>
{% if comment.mode == 1 %}
<span class="label label-valid">Valid</span>
{% elif comment.mode == 2 %}
<span class="label label-pending">Pending</span>
{% elif comment.mode == 4 %}
<span class="label label-staled">Staled</span>
{% endif %}
</span>
</div>
<div class='text'>
{% if comment.mode == 4 %}
<strong>HIDDEN</strong>. Original text: <br />
{% endif %}
<div id="isso-text-{{comment.id}}">{{comment.text}}</div>
</div>
<div class='isso-comment-footer'>
{% if conf.votes and comment.likes - comment.dislikes != 0 %}
<span class='votes'>{{comment.likes - comment.dislikes}}</span>
{% endif %}
<span class='spacer'></span>
<a id="edit-btn-{{comment.id}}" class="edit" onClick="javascript:start_edit({{comment.id}})">Edit</a>
<a id="stop-edit-btn-{{comment.id}}" class="hidden edit" onClick="javascript:stop_edit({{comment.id}})">Cancel</a>
<a id="send-edit-btn-{{comment.id}}" class="hidden edit" onClick="javascript:send_edit({{comment.id}}, '{{comment.hash}}')">Send</a>
{% if comment.mode != 4 %}
<a class="delete"
onClick="javascript:delete_com({{comment.id}}, '{{comment.hash}}')">
Delete
</a>
{% endif %}
{% if comment.mode == 2 %}
<a class='validate'
onClick="javascript:validate_com({{comment.id}}, '{{comment.hash}}')">Validate</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</main>
</div>
</body>
</html>

30
isso/templates/login.html Normal file
View File

@ -0,0 +1,30 @@
<html>
<head>
<title>Isso admin</title>
<link type="text/css" href="/css/isso.css" rel="stylesheet">
<link type="text/css" href="/css/admin.css" rel="stylesheet">
</head>
<body>
<div class="wrapper">
<div class="header">
<header>
<img class="logo" src="/img/isso.svg" alt="Wynaut by @veekun"/>
<div class="title">
<a href="./">
<h1>Isso</h1>
<h2>Administration</h2>
</a>
</div>
</header>
</div>
<main>
<div id="login">
Administration secured by password:
<form method="POST" action="/login">
<input type="password" name="password" />
</form>
</div>
</main>
</div>
</body>
</html>

View File

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

View File

@ -4,12 +4,9 @@ from __future__ import unicode_literals
import os
import json
import re
import tempfile
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
try:
from urllib.parse import urlencode
@ -36,6 +33,7 @@ class TestComments(unittest.TestCase):
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none")
self.conf = conf
class App(Isso, core.Mixin):
pass
@ -54,7 +52,8 @@ class TestComments(unittest.TestCase):
def testGet(self):
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Lorem ipsum ...'}))
r = self.get('/id/1')
self.assertEqual(r.status_code, 200)
@ -65,7 +64,8 @@ class TestComments(unittest.TestCase):
def testCreate(self):
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
rv = self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Lorem ipsum ...'}))
self.assertEqual(rv.status_code, 201)
self.assertIn("Set-Cookie", rv.headers)
@ -77,7 +77,8 @@ class TestComments(unittest.TestCase):
def textCreateWithNonAsciiText(self):
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Здравствуй, мир!'}))
rv = self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Здравствуй, мир!'}))
self.assertEqual(rv.status_code, 201)
rv = loads(rv.data)
@ -109,14 +110,16 @@ class TestComments(unittest.TestCase):
def testCreateInvalidParent(self):
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
invalid = self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 2}))
self.post('/new?uri=test',
data=json.dumps({'text': '...', 'parent': 1}))
invalid = self.post(
'/new?uri=test', data=json.dumps({'text': '...', 'parent': 2}))
self.assertEqual(loads(invalid.data)["parent"], 1)
def testVerifyFields(self):
verify = lambda comment: comments.API.verify(comment)[0]
def verify(comment): return comments.API.verify(comment)[0]
# text is missing
self.assertFalse(verify({}))
@ -132,10 +135,12 @@ class TestComments(unittest.TestCase):
# email/website length
self.assertTrue(verify({"text": "...", "email": "*"*254}))
self.assertTrue(verify({"text": "...", "website": "google.de/" + "a"*128}))
self.assertTrue(
verify({"text": "...", "website": "google.de/" + "a"*128}))
self.assertFalse(verify({"text": "...", "email": "*"*1024}))
self.assertFalse(verify({"text": "...", "website": "google.de/" + "*"*1024}))
self.assertFalse(
verify({"text": "...", "website": "google.de/" + "*"*1024}))
# valid website url
self.assertTrue(comments.isurl("example.tld"))
@ -143,7 +148,8 @@ class TestComments(unittest.TestCase):
self.assertTrue(comments.isurl("https://example.tld"))
self.assertTrue(comments.isurl("https://example.tld:1337/"))
self.assertTrue(comments.isurl("https://example.tld:1337/foobar"))
self.assertTrue(comments.isurl("https://example.tld:1337/foobar?p=1#isso-thread"))
self.assertTrue(comments.isurl(
"https://example.tld:1337/foobar?p=1#isso-thread"))
self.assertFalse(comments.isurl("ftp://example.tld/"))
self.assertFalse(comments.isurl("tel:+1234567890"))
@ -153,7 +159,8 @@ class TestComments(unittest.TestCase):
def testGetInvalid(self):
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=123').status_code, 404)
self.assertEqual(self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404)
self.assertEqual(
self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404)
self.assertEqual(self.get('/?uri=?uri=%foo%2F').status_code, 404)
def testGetLimited(self):
@ -170,7 +177,8 @@ class TestComments(unittest.TestCase):
def testGetNested(self):
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
self.post('/new?uri=test',
data=json.dumps({'text': '...', 'parent': 1}))
r = self.get('/?uri=test&parent=1')
self.assertEqual(r.status_code, 200)
@ -182,7 +190,8 @@ class TestComments(unittest.TestCase):
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
for i in range(20):
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
self.post('/new?uri=test',
data=json.dumps({'text': '...', 'parent': 1}))
r = self.get('/?uri=test&parent=1&limit=10')
self.assertEqual(r.status_code, 200)
@ -192,7 +201,8 @@ class TestComments(unittest.TestCase):
def testUpdate(self):
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Lorem ipsum ...'}))
self.put('/id/1', data=json.dumps({
'text': 'Hello World', 'author': 'me', 'website': 'http://example.com/'}))
@ -207,7 +217,8 @@ class TestComments(unittest.TestCase):
def testDelete(self):
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Lorem ipsum ...'}))
r = self.delete('/id/1')
self.assertEqual(r.status_code, 200)
self.assertEqual(loads(r.data), None)
@ -217,7 +228,8 @@ class TestComments(unittest.TestCase):
client = JSONClient(self.app, Response)
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First', 'parent': 1}))
client.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'First', 'parent': 1}))
r = client.delete('/id/1')
self.assertEqual(r.status_code, 200)
@ -246,8 +258,10 @@ class TestComments(unittest.TestCase):
client = JSONClient(self.app, Response)
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Second', 'parent': 1}))
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Third', 'parent': 1}))
client.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Second', 'parent': 1}))
client.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': 'Third', 'parent': 1}))
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Last'}))
client.delete('/id/1')
@ -265,10 +279,11 @@ class TestComments(unittest.TestCase):
for path in paths:
self.assertEqual(self.post('/new?' + urlencode({'uri': path}),
data=json.dumps({'text': '...'})).status_code, 201)
data=json.dumps({'text': '...'})).status_code, 201)
for i, path in enumerate(paths):
self.assertEqual(self.get('/?' + urlencode({'uri': path})).status_code, 200)
self.assertEqual(
self.get('/?' + urlencode({'uri': path})).status_code, 200)
self.assertEqual(self.get('/id/%i' % (i + 1)).status_code, 200)
def testDeleteAndCreateByDifferentUsersButSamePostId(self):
@ -287,7 +302,8 @@ class TestComments(unittest.TestCase):
a = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Aaa"}))
b = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Bbb"}))
c = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Ccc", "email": "..."}))
c = self.post('/new?uri=%2Fpath%2F',
data=json.dumps({"text": "Ccc", "email": "..."}))
a = loads(a.data)
b = loads(b.data)
@ -299,7 +315,8 @@ class TestComments(unittest.TestCase):
def testVisibleFields(self):
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "...", "invalid": "field"}))
rv = self.post('/new?uri=%2Fpath%2F',
data=json.dumps({"text": "...", "invalid": "field"}))
self.assertEqual(rv.status_code, 201)
rv = loads(rv.data)
@ -310,6 +327,36 @@ class TestComments(unittest.TestCase):
self.assertListEqual(list(rv.keys()), [])
def testNoFeed(self):
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
self.assertEqual(rv.status_code, 404)
def testFeedEmpty(self):
self.conf.set("rss", "base", "https://example.org")
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers['ETag'], '"empty"')
data = rv.data.decode('utf-8')
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>1970-01-01T01:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/nothing</id><title>Comments for example.org/path/nothing</title></feed>""")
def testFeed(self):
self.conf.set("rss", "base", "https://example.org")
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': '*Second*', 'parent': 1}))
rv = self.get('/feed?uri=%2Fpath%2F')
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers['ETag'], '"1-2"')
data = rv.data.decode('utf-8')
data = re.sub('[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+Z',
'2018-04-01T10:00:00Z', data)
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>2018-04-01T10:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/</id><title>Comments for example.org/path/</title><entry><id>tag:example.org,2018:/isso/1/2</id><title>Comment #2</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-2" /><content type="html">&lt;p&gt;&lt;em&gt;Second&lt;/em&gt;&lt;/p&gt;</content><thr:in-reply-to href="https://example.org/path/#isso-1" ref="tag:example.org,2018:/isso/1/1" /></entry><entry><id>tag:example.org,2018:/isso/1/1</id><title>Comment #1</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-1" /><content type="html">&lt;p&gt;First&lt;/p&gt;</content></entry></feed>""")
def testCounts(self):
self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404)
@ -338,7 +385,8 @@ class TestComments(unittest.TestCase):
for uri, count in iteritems(expected):
for _ in range(count):
self.post('/new?uri=%s' % uri, data=json.dumps({"text": "..."}))
self.post('/new?uri=%s' %
uri, data=json.dumps({"text": "..."}))
rv = self.post('/count', data=json.dumps(list(expected.keys())))
self.assertEqual(loads(rv.data), list(expected.values()))
@ -367,22 +415,26 @@ class TestComments(unittest.TestCase):
self.post('/new?uri=%2F', data=json.dumps({"text": "..."}))
# no header is fine (default for XHR)
self.assertEqual(self.post('/id/1/dislike', content_type="").status_code, 200)
self.assertEqual(
self.post('/id/1/dislike', content_type="").status_code, 200)
# x-www-form-urlencoded is definitely not RESTful
self.assertEqual(self.post('/id/1/dislike', content_type=form).status_code, 403)
self.assertEqual(
self.post('/id/1/dislike', content_type=form).status_code, 403)
self.assertEqual(self.post('/new?uri=%2F', data=json.dumps({"text": "..."}),
content_type=form).status_code, 403)
content_type=form).status_code, 403)
# just for the record
self.assertEqual(self.post('/id/1/dislike', content_type=js).status_code, 200)
self.assertEqual(
self.post('/id/1/dislike', content_type=js).status_code, 200)
def testPreview(self):
response = self.post('/preview', data=json.dumps({'text': 'This is **mark***down*'}))
response = self.post(
'/preview', data=json.dumps({'text': 'This is **mark***down*'}))
self.assertEqual(response.status_code, 200)
rv = loads(response.data)
self.assertEqual(rv["text"], '<p>This is <strong>mark</strong><em>down</em></p>')
self.assertEqual(
rv["text"], '<p>This is <strong>mark</strong><em>down</em></p>')
class TestModeratedComments(unittest.TestCase):
@ -407,7 +459,8 @@ class TestModeratedComments(unittest.TestCase):
def testAddComment(self):
rv = self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
rv = self.client.post(
'/new?uri=test', data=json.dumps({"text": "..."}))
self.assertEqual(rv.status_code, 202)
self.assertEqual(self.client.get('/id/1').status_code, 200)

View File

@ -1,10 +1,6 @@
# -*- encoding: utf-8 -*-
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
import io
from isso import config

View File

@ -1,10 +1,7 @@
from __future__ import unicode_literals
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
from werkzeug.test import Client
from werkzeug.wrappers import Response
@ -22,31 +19,37 @@ class CORSTest(unittest.TestCase):
def test_simple(self):
app = CORSMiddleware(hello_world,
origin=origin([
"https://example.tld/",
"http://example.tld/",
]),
allowed=("Foo", "Bar"), exposed=("Spam", ))
origin=origin([
"https://example.tld/",
"http://example.tld/",
]),
allowed=("Foo", "Bar"), exposed=("Spam", ))
client = Client(app, Response)
rv = client.get("/", headers={"Origin": "https://example.tld"})
self.assertEqual(rv.headers["Access-Control-Allow-Origin"], "https://example.tld")
self.assertEqual(rv.headers["Access-Control-Allow-Credentials"], "true")
self.assertEqual(rv.headers["Access-Control-Allow-Methods"], "HEAD, GET, POST, PUT, DELETE")
self.assertEqual(rv.headers["Access-Control-Allow-Headers"], "Foo, Bar")
self.assertEqual(
rv.headers["Access-Control-Allow-Origin"], "https://example.tld")
self.assertEqual(
rv.headers["Access-Control-Allow-Credentials"], "true")
self.assertEqual(
rv.headers["Access-Control-Allow-Methods"], "HEAD, GET, POST, PUT, DELETE")
self.assertEqual(
rv.headers["Access-Control-Allow-Headers"], "Foo, Bar")
self.assertEqual(rv.headers["Access-Control-Expose-Headers"], "Spam")
a = client.get("/", headers={"Origin": "http://example.tld"})
self.assertEqual(a.headers["Access-Control-Allow-Origin"], "http://example.tld")
self.assertEqual(
a.headers["Access-Control-Allow-Origin"], "http://example.tld")
b = client.get("/", headers={"Origin": "http://example.tld"})
self.assertEqual(b.headers["Access-Control-Allow-Origin"], "http://example.tld")
self.assertEqual(
b.headers["Access-Control-Allow-Origin"], "http://example.tld")
c = client.get("/", headers={"Origin": "http://foo.other"})
self.assertEqual(c.headers["Access-Control-Allow-Origin"], "https://example.tld")
self.assertEqual(
c.headers["Access-Control-Allow-Origin"], "https://example.tld")
def test_preflight(self):
@ -54,10 +57,12 @@ class CORSTest(unittest.TestCase):
allowed=("Foo", ), exposed=("Bar", ))
client = Client(app, Response)
rv = client.open(method="OPTIONS", path="/", headers={"Origin": "http://example.tld"})
rv = client.open(method="OPTIONS", path="/",
headers={"Origin": "http://example.tld"})
self.assertEqual(rv.status_code, 200)
for hdr in ("Origin", "Headers", "Credentials", "Methods"):
self.assertIn("Access-Control-Allow-%s" % hdr, rv.headers)
self.assertEqual(rv.headers["Access-Control-Allow-Origin"], "http://example.tld")
self.assertEqual(
rv.headers["Access-Control-Allow-Origin"], "http://example.tld")

View File

@ -1,10 +1,6 @@
# -*- encoding: utf-8 -*-
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
import os
import sqlite3
import tempfile
@ -65,14 +61,15 @@ class TestDBMigration(unittest.TestCase):
"supersecretkey")
def test_limit_nested_comments(self):
"""Transform previously A -> B -> C comment nesting to A -> B, A -> C"""
tree = {
1: None,
2: None,
3: 2,
4: 3,
7: 3,
5: 2,
3: 2,
4: 3,
7: 3,
5: 2,
6: None
}
@ -94,10 +91,11 @@ class TestDBMigration(unittest.TestCase):
" dislikes INTEGER DEFAULT 0,"
" voters BLOB)")
con.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", ("/", "Test"))
con.execute(
"INSERT INTO threads (uri, title) VALUES (?, ?)", ("/", "Test"))
for (id, parent) in iteritems(tree):
con.execute("INSERT INTO comments ("
" tid, parent, created)"
" id, parent, created)"
"VALUEs (?, ?, ?)", (id, parent, id))
conf = config.new({
@ -108,16 +106,17 @@ class TestDBMigration(unittest.TestCase):
})
SQLite3(self.path, conf)
flattened = [
(1, None),
(2, None),
(3, 2),
(4, 2),
(5, 2),
(6, None),
(7, 2)
]
flattened = list(iteritems({
1: None,
2: None,
3: 2,
4: 2,
5: 2,
6: None,
7: 2
}))
with sqlite3.connect(self.path) as con:
rv = con.execute("SELECT id, parent FROM comments ORDER BY created").fetchall()
rv = con.execute(
"SELECT id, parent FROM comments ORDER BY created").fetchall()
self.assertEqual(flattened, rv)

View File

@ -2,11 +2,7 @@
from __future__ import unicode_literals
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
import os
import json
import tempfile
@ -72,7 +68,8 @@ class TestGuard(unittest.TestCase):
alice = self.makeClient("1.2.3.4", 2)
for i in range(2):
self.assertEqual(alice.post("/new?uri=test", data=self.data).status_code, 201)
self.assertEqual(alice.post(
"/new?uri=test", data=self.data).status_code, 201)
bob.application.db.execute([
"UPDATE comments SET",
@ -80,7 +77,8 @@ class TestGuard(unittest.TestCase):
"WHERE remote_addr = '127.0.0.0'"
])
self.assertEqual(bob.post("/new?uri=test", data=self.data).status_code, 201)
self.assertEqual(
bob.post("/new?uri=test", data=self.data).status_code, 201)
def testDirectReply(self):
@ -99,11 +97,13 @@ class TestGuard(unittest.TestCase):
def testSelfReply(self):
payload = lambda id: json.dumps({"text": "...", "parent": id})
def payload(id): return json.dumps({"text": "...", "parent": id})
client = self.makeClient("127.0.0.1", self_reply=False)
self.assertEqual(client.post("/new?uri=test", data=self.data).status_code, 201)
self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 403)
self.assertEqual(client.post(
"/new?uri=test", data=self.data).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload(1)).status_code, 403)
client.application.db.execute([
"UPDATE comments SET",
@ -111,39 +111,55 @@ class TestGuard(unittest.TestCase):
"WHERE id = 1"
], (client.application.conf.getint("general", "max-age"), ))
self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload(1)).status_code, 201)
client = self.makeClient("128.0.0.1", ratelimit=3, self_reply=False)
self.assertEqual(client.post("/new?uri=test", data=self.data).status_code, 201)
self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 201)
self.assertEqual(client.post("/new?uri=test", data=payload(2)).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=self.data).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload(1)).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload(2)).status_code, 201)
def testRequireEmail(self):
payload = lambda email: json.dumps({"text": "...", "email": email})
def payload(email): return json.dumps({"text": "...", "email": email})
client = self.makeClient("127.0.0.1", ratelimit=4, require_email=False)
client_strict = self.makeClient("127.0.0.2", ratelimit=4, require_email=True)
client_strict = self.makeClient(
"127.0.0.2", ratelimit=4, require_email=True)
# if we don't require email
self.assertEqual(client.post("/new?uri=test", data=payload("")).status_code, 201)
self.assertEqual(client.post("/new?uri=test", data=payload("test@me.more")).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload("")).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload("test@me.more")).status_code, 201)
# if we do require email
self.assertEqual(client_strict.post("/new?uri=test", data=payload("")).status_code, 403)
self.assertEqual(client_strict.post("/new?uri=test", data=payload("test@me.more")).status_code, 201)
self.assertEqual(client_strict.post(
"/new?uri=test", data=payload("")).status_code, 403)
self.assertEqual(client_strict.post(
"/new?uri=test", data=payload("test@me.more")).status_code, 201)
def testRequireAuthor(self):
payload = lambda author: json.dumps({"text": "...", "author": author})
def payload(author): return json.dumps(
{"text": "...", "author": author})
client = self.makeClient("127.0.0.1", ratelimit=4, require_author=False)
client_strict = self.makeClient("127.0.0.2", ratelimit=4, require_author=True)
client = self.makeClient(
"127.0.0.1", ratelimit=4, require_author=False)
client_strict = self.makeClient(
"127.0.0.2", ratelimit=4, require_author=True)
# if we don't require author
self.assertEqual(client.post("/new?uri=test", data=payload("")).status_code, 201)
self.assertEqual(client.post("/new?uri=test", data=payload("pipo author")).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload("")).status_code, 201)
self.assertEqual(client.post(
"/new?uri=test", data=payload("pipo author")).status_code, 201)
# if we do require author
self.assertEqual(client_strict.post("/new?uri=test", data=payload("")).status_code, 403)
self.assertEqual(client_strict.post("/new?uri=test", data=payload("pipo author")).status_code, 201)
self.assertEqual(client_strict.post(
"/new?uri=test", data=payload("")).status_code, 403)
self.assertEqual(client_strict.post(
"/new?uri=test", data=payload("pipo author")).status_code, 201)

View File

@ -1,10 +1,6 @@
# -*- encoding: utf-8 -*-
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
import textwrap
from isso import config
@ -68,13 +64,18 @@ class TestHTML(unittest.TestCase):
sanitizer = html.Sanitizer(elements=[], attributes=[])
examples = [
('Look: <img src="..." />', 'Look: '),
('<a href="http://example.org/">Ha</a>', '<a href="http://example.org/">Ha</a>'),
('<a href="http://example.org/">Ha</a>',
['<a href="http://example.org/" rel="nofollow noopener">Ha</a>',
'<a rel="nofollow noopener" href="http://example.org/">Ha</a>']),
('<a href="sms:+1234567890">Ha</a>', '<a>Ha</a>'),
('<p style="visibility: hidden;">Test</p>', '<p>Test</p>'),
('<script>alert("Onoe")</script>', 'alert("Onoe")')]
for (input, expected) in examples:
self.assertEqual(html.sanitize(sanitizer, input), expected)
if isinstance(expected, list):
self.assertIn(html.sanitize(sanitizer, input), expected)
else:
self.assertEqual(html.sanitize(sanitizer, input), expected)
@unittest.skipIf(html.HTML5LIB_VERSION <= html.HTML5LIB_SIMPLETREE, "backport")
def test_sanitizer_extensions(self):
@ -95,5 +96,6 @@ class TestHTML(unittest.TestCase):
}
})
renderer = html.Markup(conf.section("markup")).render
self.assertEqual(renderer("http://example.org/ and sms:+1234567890"),
'<p><a href="http://example.org/">http://example.org/</a> and sms:+1234567890</p>')
self.assertIn(renderer("http://example.org/ and sms:+1234567890"),
['<p><a href="http://example.org/" rel="nofollow noopener">http://example.org/</a> and sms:+1234567890</p>',
'<p><a rel="nofollow noopener" href="http://example.org/">http://example.org/</a> and sms:+1234567890</p>'])

View File

@ -2,11 +2,7 @@
from __future__ import unicode_literals
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
import tempfile
from os.path import join, dirname
@ -33,7 +29,8 @@ class TestMigration(unittest.TestCase):
db = SQLite3(xxx.name, conf)
Disqus(db, xml).migrate()
self.assertEqual(len(db.execute("SELECT id FROM comments").fetchall()), 2)
self.assertEqual(
len(db.execute("SELECT id FROM comments").fetchall()), 2)
self.assertEqual(db.threads["/"]["title"], "Hello, World!")
self.assertEqual(db.threads["/"]["id"], 1)
@ -61,8 +58,10 @@ class TestMigration(unittest.TestCase):
self.assertEqual(db.threads["/?p=4"]["title"], "...")
self.assertEqual(db.threads["/?p=4"]["id"], 2)
self.assertEqual(len(db.execute("SELECT id FROM threads").fetchall()), 2)
self.assertEqual(len(db.execute("SELECT id FROM comments").fetchall()), 7)
self.assertEqual(
len(db.execute("SELECT id FROM threads").fetchall()), 2)
self.assertEqual(
len(db.execute("SELECT id FROM comments").fetchall()), 7)
first = db.comments.get(1)
self.assertEqual(first["author"], "Ohai")

View File

@ -1,10 +1,6 @@
# -*- encoding: utf-8 -*-
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
from isso import utils
from isso.utils import parse
@ -16,7 +12,8 @@ class TestUtils(unittest.TestCase):
examples = [
(u'12.34.56.78', u'12.34.56.0'),
(u'1234:5678:90ab:cdef:fedc:ba09:8765:4321', u'1234:5678:90ab:0000:0000:0000:0000:0000'),
(u'1234:5678:90ab:cdef:fedc:ba09:8765:4321',
u'1234:5678:90ab:0000:0000:0000:0000:0000'),
(u'::ffff:127.0.0.1', u'127.0.0.0')]
for (addr, anonymized) in examples:
@ -60,4 +57,4 @@ class TestParse(unittest.TestCase):
"""), ('test', 'Test'))
self.assertEqual(parse.thread('<section id="isso-thread" data-isso-id="Fuu.">'),
('Fuu.', 'Untitled.'))
('Fuu.', 'Untitled.'))

View File

@ -2,10 +2,7 @@
from __future__ import unicode_literals
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
from isso import config
@ -36,7 +33,8 @@ class TestHasher(unittest.TestCase):
class TestPBKDF2(unittest.TestCase):
def test_default(self):
pbkdf2 = PBKDF2(iterations=1000) # original setting (and still default)
# original setting (and still default)
pbkdf2 = PBKDF2(iterations=1000)
self.assertEqual(pbkdf2.uhash(""), "42476aafe2e4")
def test_different_salt(self):
@ -70,4 +68,4 @@ class TestCreate(unittest.TestCase):
pbkdf2 = _new("pbkdf2:16:2:md5")
self.assertIsInstance(pbkdf2, PBKDF2)
self.assertEqual(pbkdf2.dklen, 2)
self.assertEqual(pbkdf2.func, "md5")
self.assertEqual(pbkdf2.func, "md5")

View File

@ -4,11 +4,7 @@ from __future__ import unicode_literals
import os
import json
import tempfile
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
from werkzeug.wrappers import Response
@ -41,13 +37,15 @@ class TestVote(unittest.TestCase):
def testZeroLikes(self):
rv = self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
rv = self.makeClient("127.0.0.1").post(
"/new?uri=test", data=json.dumps({"text": "..."}))
self.assertEqual(loads(rv.data)['likes'], 0)
self.assertEqual(loads(rv.data)['dislikes'], 0)
def testSingleLike(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
self.makeClient("127.0.0.1").post(
"/new?uri=test", data=json.dumps({"text": "..."}))
rv = self.makeClient("0.0.0.0").post("/id/1/like")
self.assertEqual(rv.status_code, 200)
@ -64,7 +62,8 @@ class TestVote(unittest.TestCase):
def testMultipleLikes(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
self.makeClient("127.0.0.1").post(
"/new?uri=test", data=json.dumps({"text": "..."}))
for num in range(15):
rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like')
self.assertEqual(rv.status_code, 200)
@ -77,7 +76,8 @@ class TestVote(unittest.TestCase):
def testTooManyLikes(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
self.makeClient("127.0.0.1").post(
"/new?uri=test", data=json.dumps({"text": "..."}))
for num in range(256):
rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like')
self.assertEqual(rv.status_code, 200)
@ -88,7 +88,8 @@ class TestVote(unittest.TestCase):
self.assertEqual(loads(rv.data)["likes"], num + 1)
def testDislike(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
self.makeClient("127.0.0.1").post(
"/new?uri=test", data=json.dumps({"text": "..."}))
rv = self.makeClient("1.2.3.4").post('/id/1/dislike')
self.assertEqual(rv.status_code, 200)

View File

@ -1,10 +1,6 @@
# -*- encoding: utf-8 -*-
try:
import unittest2 as unittest
except ImportError:
import unittest
import unittest
from isso import wsgi

View File

@ -5,9 +5,12 @@ from __future__ import division, unicode_literals
import pkg_resources
werkzeug = pkg_resources.get_distribution("werkzeug")
import json
import hashlib
import json
import os
from datetime import datetime
from jinja2 import Environment, FileSystemLoader
from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest
@ -86,11 +89,11 @@ class Bloomfilter:
def add(self, key):
for i in self.get_probes(key):
self.array[i//8] |= 2 ** (i%8)
self.array[i//8] |= 2 ** (i % 8)
self.elements += 1
def __contains__(self, key):
return all(self.array[i//8] & (2 ** (i%8)) for i in self.get_probes(key))
return all(self.array[i//8] & (2 ** (i % 8)) for i in self.get_probes(key))
def __len__(self):
return self.elements
@ -109,9 +112,30 @@ class JSONRequest(Request):
raise BadRequest('Unable to read JSON request')
def render_template(template_name, **context):
template_path = os.path.join(os.path.dirname(__file__),
'..', 'templates')
jinja_env = Environment(loader=FileSystemLoader(template_path),
autoescape=True)
def datetimeformat(value):
return datetime.fromtimestamp(value).strftime('%H:%M / %d-%m-%Y')
jinja_env.filters['datetimeformat'] = datetimeformat
t = jinja_env.get_template(template_name)
return Response(t.render(context), mimetype='text/html')
class JSONResponse(Response):
def __init__(self, obj, *args, **kwargs):
kwargs["content_type"] = "application/json"
super(JSONResponse, self).__init__(
json.dumps(obj).encode("utf-8"), *args, **kwargs)
class XMLResponse(Response):
def __init__(self, obj, *args, **kwargs):
kwargs["content_type"] = "text/xml"
super(XMLResponse, self).__init__(
obj, *args, **kwargs)

View File

@ -73,7 +73,7 @@ class Hash(object):
class PBKDF2(Hash):
def __init__(self, salt=None, iterations=1000, dklen=6, func="sha1"):
super(PBKDF2, self).__init__(salt)
@ -110,4 +110,4 @@ def new(conf):
sha1 = Hash(func="sha1").uhash
md5 = Hash(func="md5").uhash
md5 = Hash(func="md5").uhash

View File

@ -50,11 +50,17 @@ def sanitize(tokenizer, document):
if HTML5LIB_VERSION > HTML5LIB_SIMPLETREE:
builder = "etree"
for link in domtree.findall(".//{http://www.w3.org/1999/xhtml}a"):
if link.get('href', None):
link.set("rel", "nofollow noopener")
else:
builder = "simpletree"
stream = html5lib.treewalkers.getTreeWalker(builder)(domtree)
serializer = HTMLSerializer(quote_attr_values=True, omit_optional_tags=False)
serializer = HTMLSerializer(
quote_attr_values=True, omit_optional_tags=False)
return serializer.render(stream)

View File

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

View File

@ -15,7 +15,7 @@ from isso.compat import map, filter, PY2K
if PY2K: # http://bugs.python.org/issue12984
from xml.dom.minidom import NamedNodeMap
NamedNodeMap.__contains__ = lambda self, key: self.has_key(key)
NamedNodeMap.__contains__ = lambda self, key: self.has_key(key) # noqa
def thread(data, default=u"Untitled.", id=None):
@ -31,8 +31,8 @@ def thread(data, default=u"Untitled.", id=None):
# aka getElementById, but limited to div and section tags
el = list(filter(lambda i: i.attributes["id"].value == "isso-thread",
filter(lambda i: "id" in i.attributes,
chain(*map(html.getElementsByTagName, ("div", "section"))))))
filter(lambda i: "id" in i.attributes,
chain(*map(html.getElementsByTagName, ("div", "section"))))))
if not el:
return id, default

View File

@ -40,7 +40,8 @@ class requires:
try:
kwargs[self.param] = self.type(req.args[self.param])
except TypeError:
raise BadRequest("invalid type for %s, expected %s" % (self.param, self.type))
raise BadRequest("invalid type for %s, expected %s" %
(self.param, self.type))
return func(cls, env, req, *args, **kwargs)

View File

@ -7,7 +7,9 @@ import cgi
import time
import functools
from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET
from werkzeug.http import dump_cookie
from werkzeug.wsgi import get_current_url
@ -19,16 +21,28 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str
from isso import utils, local
from isso.utils import http, parse, JSONResponse as JSON
from isso.utils import (http, parse,
JSONResponse as JSON, XMLResponse as XML,
render_template)
from isso.views import requires
from isso.utils.hash import sha1
from isso.utils.hash import md5
try:
from urlparse import urlparse
except ImportError:
from urllib.parse import urlparse
try:
from StringIO import StringIO
except ImportError:
from io import BytesIO as StringIO
# from Django appearently, looks good to me *duck*
__url_re = re.compile(
r'^'
r'(https?://)?'
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
# domain...
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
@ -61,6 +75,12 @@ def xhr(func):
not forged (XHR is restricted by CORS separately).
"""
"""
@apiDefine csrf
@apiHeader {string="application/json"} Content-Type
The content type must be set to `application/json` to prevent CSRF attacks.
"""
def dec(self, env, req, *args, **kwargs):
if req.content_type and not req.content_type.startswith("application/json"):
@ -83,15 +103,18 @@ class API(object):
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('moderate',('GET', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
('moderate',('POST', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')),
('preview', ('POST', '/preview'))
('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
]
def __init__(self, isso, hasher):
@ -142,6 +165,78 @@ class API(object):
return True, ""
# Common definitions for apidoc follow:
"""
@apiDefine plainParam
@apiParam {number=0,1} [plain]
Iff set to `1`, the plain text entered by the user will be returned in the comments `text` attribute (instead of the rendered markdown).
"""
"""
@apiDefine commentResponse
@apiSuccess {number} id
The comments id (assigned by the server).
@apiSuccess {number} parent
Id of the comment this comment is a reply to. `null` if this is a top-level-comment.
@apiSuccess {number=1,2,4} mode
The comments mode:
value | explanation
--- | ---
`1` | accepted: The comment was accepted by the server and is published.
`2` | in moderation queue: The comment was accepted by the server but awaits moderation.
`4` | deleted, but referenced: The comment was deleted on the server but is still referenced by replies.
@apiSuccess {string} author
The commentss authors name or `null`.
@apiSuccess {string} website
The comments authors website or `null`.
@apiSuccess {string} hash
A hash uniquely identifying the comments author.
@apiSuccess {number} created
UNIX timestamp of the time the comment was created (on the server).
@apiSuccess {number} modified
UNIX timestamp of the time the comment was last modified (on the server). `null` if the comment was not yet modified.
"""
"""
@api {post} /new create new
@apiGroup Comment
@apiDescription
Creates a new comment. The response will set a cookie on the requestor to enable them to later edit the comment.
@apiUse csrf
@apiParam {string} uri
The uri of the thread to create the comment on.
@apiParam {string} text
The comments raw text.
@apiParam {string} [author]
The comments authors name.
@apiParam {string} [email]
The comments authors email address.
@apiParam {string} [website]
The comments authors websites url.
@apiParam {number} [parent]
The parent comments id iff the new comment is a response to an existing comment.
@apiExample {curl} Create a reply to comment with id 15:
curl 'https://comments.example.com/new?uri=/thread/' -d '{"text": "Stop saying that! *isso*!", "author": "Max Rant", "email": "rant@example.com", "parent": 15}' -H 'Content-Type: application/json' -c cookie.txt
@apiUse commentResponse
@apiSuccessExample Success after the above request:
{
"website": null,
"author": "Max Rant",
"parent": 15,
"created": 1464940838.254393,
"text": "&lt;p&gt;Stop saying that! &lt;em&gt;isso&lt;/em&gt;!&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"hash": "e644f6ee43c0",
"id": 23,
"likes": 0
}
"""
@xhr
@requires(str, 'uri')
def new(self, environ, request, uri):
@ -199,13 +294,15 @@ class API(object):
self.signal("comments.new:after-save", thread, rv)
cookie = functools.partial(dump_cookie,
value=self.isso.sign([rv["id"], sha1(rv["text"])]),
max_age=self.conf.getint('max-age'))
value=self.isso.sign(
[rv["id"], sha1(rv["text"])]),
max_age=self.conf.getint('max-age'))
rv["text"] = self.isso.render(rv["text"])
rv["hash"] = self.hash(rv['email'] or rv['remote_addr'])
self.cache.set('hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
self.cache.set(
'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
rv = self._add_gravatar_image(rv)
@ -220,6 +317,34 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
"""
@api {get} /id/:id view
@apiGroup Comment
@apiParam {number} id
The id of the comment to view.
@apiUse plainParam
@apiExample {curl} View the comment with id 4:
curl 'https://comments.example.com/id/4'
@apiUse commentResponse
@apiSuccessExample Example result:
{
"website": null,
"author": null,
"parent": null,
"created": 1464914341.312426,
"text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"id": 4,
"likes": 1
}
"""
def view(self, environ, request, id):
rv = self.comments.get(id)
@ -234,6 +359,41 @@ class API(object):
return JSON(rv, 200)
"""
@api {put} /id/:id edit
@apiGroup Comment
@apiDescription
Edit an existing comment. Editing a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details. Editing a comment will set a new edit cookie in the response.
@apiUse csrf
@apiParam {number} id
The id of the comment to edit.
@apiParam {string} text
A new (raw) text for the comment.
@apiParam {string} [author]
The modified comments authors name.
@apiParam {string} [webiste]
The modified comments authors website.
@apiExample {curl} Edit comment with id 23:
curl -X PUT 'https://comments.example.com/id/23' -d {"text": "I see your point. However, I still disagree.", "website": "maxrant.important.com"} -H 'Content-Type: application/json' -b cookie.txt
@apiUse commentResponse
@apiSuccessExample Example response:
{
"website": "maxrant.important.com",
"author": "Max Rant",
"parent": 15,
"created": 1464940838.254393,
"text": "&lt;p&gt;I see your point. However, I still disagree.&lt;/p&gt;",
"dislikes": 0,
"modified": 1464943439.073961,
"mode": 1,
"id": 23,
"likes": 0
}
"""
@xhr
def edit(self, environ, request, id):
@ -268,8 +428,9 @@ class API(object):
self.signal("comments.edit", rv)
cookie = functools.partial(dump_cookie,
value=self.isso.sign([rv["id"], sha1(rv["text"])]),
max_age=self.conf.getint('max-age'))
value=self.isso.sign(
[rv["id"], sha1(rv["text"])]),
max_age=self.conf.getint('max-age'))
rv["text"] = self.isso.render(rv["text"])
@ -278,6 +439,21 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
"""
@api {delete} '/id/:id' delete
@apiGroup Comment
@apiDescription
Delte an existing comment. Deleting a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details.
@apiParam {number} id
Id of the comment to delete.
@apiExample {curl} Delete comment with id 14:
curl -X DELETE 'https://comments.example.com/id/14' -b cookie.txt
@apiSuccessExample Successful deletion returns null:
null
"""
@xhr
def delete(self, environ, request, id, key=None):
@ -298,7 +474,8 @@ class API(object):
if item is None:
raise NotFound
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
self.cache.delete(
'hash', (item['email'] or item['remote_addr']).encode('utf-8'))
with self.isso.lock:
rv = self.comments.delete(id)
@ -315,8 +492,42 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
def moderate(self, environ, request, id, action, key):
"""
@api {post} /id/:id/:action/key moderate
@apiGroup Comment
@apiDescription
Publish or delete a comment that is in the moderation queue (mode `2`). In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.
This endpoint can also be used with a `GET` request. In that case, a html page is returned that asks the user whether they are sure to perform the selected action. If they select yes, the query is repeated using `POST`.
@apiParam {number} id
The id of the comment to moderate.
@apiParam {string=activate,delete} action
`activate` to publish the comment (change its mode to `1`).
`delete` to delete the comment
@apiParam {string} key
The moderation key to authenticate the moderation.
@apiExample {curl} delete comment with id 13:
curl -X POST 'https://comments.example.com/id/13/delete/MTM.CjL6Fg.REIdVXa-whJS_x8ojQL4RrXnuF4'
@apiSuccessExample {html} Using GET:
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;script&gt;
if (confirm('Delete: Are you sure?')) {
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
}
&lt;/script&gt;
@apiSuccessExample Using POST:
Yo
"""
def moderate(self, environ, request, id, action, key):
try:
id = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
@ -346,14 +557,105 @@ class API(object):
with self.isso.lock:
self.comments.activate(id)
self.signal("comments.activate", id)
return Response("Yo", 200)
elif action == "edit":
data = request.get_json()
with self.isso.lock:
rv = self.comments.update(id, data)
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
self.signal("comments.edit", rv)
return JSON(rv, 200)
else:
with self.isso.lock:
self.comments.delete(id)
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
self.cache.delete(
'hash', (item['email'] or item['remote_addr']).encode('utf-8'))
self.signal("comments.delete", id)
return Response("Yo", 200)
return Response("Yo", 200)
"""
@api {get} / get comments
@apiGroup Thread
@apiDescription Queries the comments of a thread.
@apiParam {string} uri
The URI of thread to get the comments from.
@apiParam {number} [parent]
Return only comments that are children of the comment with the provided ID.
@apiUse plainParam
@apiParam {number} [limit]
The maximum number of returned top-level comments. Omit for unlimited results.
@apiParam {number} [nested_limit]
The maximum number of returned nested comments per commint. Omit for unlimited results.
@apiParam {number} [after]
Includes only comments were added after the provided UNIX timestamp.
@apiSuccess {number} total_replies
The number of replies if the `limit` parameter was not set. If `after` is set to `X`, this is the number of comments that were created after `X`. So setting `after` may change this value!
@apiSuccess {Object[]} replies
The list of comments. Each comment also has the `total_replies`, `replies`, `id` and `hidden_replies` properties to represent nested comments.
@apiSuccess {number} id
Id of the comment `replies` is the list of replies of. `null` for the list of toplevel comments.
@apiSuccess {number} hidden_replies
The number of comments that were ommited from the results because of the `limit` request parameter. Usually, this will be `total_replies` - `limit`.
@apiExample {curl} Get 2 comments with 5 responses:
curl 'https://comments.example.com/?uri=/thread/&limit=2&nested_limit=5'
@apiSuccessExample Example reponse:
{
"total_replies": 14,
"replies": [
{
"website": null,
"author": null,
"parent": null,
"created": 1464818460.732863,
"text": "&lt;p&gt;Hello, World!&lt;/p&gt;",
"total_replies": 1,
"hidden_replies": 0,
"dislikes": 2,
"modified": null,
"mode": 1,
"replies": [
{
"website": null,
"author": null,
"parent": 1,
"created": 1464818460.769638,
"text": "&lt;p&gt;Hi, now some Markdown: &lt;em&gt;Italic&lt;/em&gt;, &lt;strong&gt;bold&lt;/strong&gt;, &lt;code&gt;monospace&lt;/code&gt;.&lt;/p&gt;",
"dislikes": 0,
"modified": null,
"mode": 1,
"hash": "2af4e1a6c96a",
"id": 2,
"likes": 2
}
],
"hash": "1cb6cc0309a2",
"id": 1,
"likes": 2
},
{
"website": null,
"author": null,
"parent": null,
"created": 1464818460.80574,
"text": "&lt;p&gt;Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium at commodi cum deserunt dolore, error fugiat harum incidunt, ipsa ipsum mollitia nam provident rerum sapiente suscipit tempora vitae? Est, qui?&lt;/p&gt;",
"total_replies": 0,
"hidden_replies": 0,
"dislikes": 0,
"modified": null,
"mode": 1,
"replies": [],
"hash": "1cb6cc0309a2",
"id": 3,
"likes": 0
},
"id": null,
"hidden_replies": 12
}
"""
@requires(str, 'uri')
def fetch(self, environ, request, uri):
@ -401,10 +703,10 @@ class API(object):
return BadRequest("nested_limit should be integer")
rv = {
'id' : root_id,
'total_replies' : reply_counts[root_id],
'hidden_replies' : reply_counts[root_id] - len(root_list),
'replies' : self._process_fetched_list(root_list, plain)
'id': root_id,
'total_replies': reply_counts[root_id],
'hidden_replies': reply_counts[root_id] - len(root_list),
'replies': self._process_fetched_list(root_list, plain)
}
# We are only checking for one level deep comments
if root_id is None:
@ -425,7 +727,8 @@ class API(object):
comment['total_replies'] = 0
replies = []
comment['hidden_replies'] = comment['total_replies'] - len(replies)
comment['hidden_replies'] = comment['total_replies'] - \
len(replies)
comment['replies'] = self._process_fetched_list(replies, plain)
return JSON(rv, 200)
@ -463,16 +766,66 @@ class API(object):
return fetched_list
"""
@apiDefine likeResponse
@apiSuccess {number} likes
The (new) number of likes on the comment.
@apiSuccess {number} dislikes
The (new) number of dislikes on the comment.
"""
"""
@api {post} /id/:id/like like
@apiGroup Comment
@apiDescription
Puts a like on a comment. The author of a comment cannot like its own comment.
@apiParam {number} id
The id of the comment to like.
@apiExample {curl} Like comment with id 23:
curl -X POST 'https://comments.example.com/id/23/like'
@apiUse likeResponse
@apiSuccessExample Example response
{
"likes": 5,
"dislikes": 2
}
"""
@xhr
def like(self, environ, request, id):
nv = self.comments.vote(True, id, utils.anonymize(str(request.remote_addr)))
nv = self.comments.vote(
True, id, utils.anonymize(str(request.remote_addr)))
return JSON(nv, 200)
"""
@api {post} /id/:id/dislike dislike
@apiGroup Comment
@apiDescription
Puts a dislike on a comment. The author of a comment cannot dislike its own comment.
@apiParam {number} id
The id of the comment to dislike.
@apiExample {curl} Dislike comment with id 23:
curl -X POST 'https://comments.example.com/id/23/dislike'
@apiUse likeResponse
@apiSuccessExample Example response
{
"likes": 4,
"dislikes": 3
}
"""
@xhr
def dislike(self, environ, request, id):
nv = self.comments.vote(False, id, utils.anonymize(str(request.remote_addr)))
nv = self.comments.vote(
False, id, utils.anonymize(str(request.remote_addr)))
return JSON(nv, 200)
# TODO: remove someday (replaced by :func:`counts`)
@ -486,6 +839,19 @@ class API(object):
return JSON(rv, 200)
"""
@api {post} /count count comments
@apiGroup Thread
@apiDescription
Counts the number of comments on multiple threads. The requestor provides a list of thread uris. The number of comments on each thread is returned as a list, in the same order as the threads were requested. The counts include comments that are reponses to comments.
@apiExample {curl} get the count of 5 threads:
curl 'https://comments.example.com/count' -d '["/blog/firstPost.html", "/blog/controversalPost.html", "/blog/howToCode.html", "/blog/boringPost.html", "/blog/isso.html"]
@apiSuccessExample Counts of 5 threads:
[2, 18, 4, 0, 3]
"""
def counts(self, environ, request):
data = request.get_json()
@ -495,6 +861,125 @@ class API(object):
return JSON(self.comments.count(*data), 200)
"""
@api {get} /feed Atom feed for comments
@apiGroup Thread
@apiDescription
Provide an Atom feed for the given thread.
"""
@requires(str, 'uri')
def feed(self, environ, request, uri):
conf = self.isso.conf.section("rss")
if not conf.get('base'):
raise NotFound
args = {
'uri': uri,
'order_by': 'id',
'asc': 0,
'limit': conf.getint('limit')
}
try:
args['limit'] = max(int(request.args.get('limit')), args['limit'])
except TypeError:
pass
except ValueError:
return BadRequest("limit should be integer")
comments = self.comments.fetch(**args)
base = conf.get('base')
hostname = urlparse(base).netloc
# Let's build an Atom feed.
# RFC 4287: https://tools.ietf.org/html/rfc4287
# RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions)
# For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
feed = ET.Element('feed', {
'xmlns': 'http://www.w3.org/2005/Atom',
'xmlns:thr': 'http://purl.org/syndication/thread/1.0'
})
# For feed ID, we would use thread ID, but we may not have
# one. Therefore, we use the URI. We don't have a year
# either...
id = ET.SubElement(feed, 'id')
id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format(
hostname=hostname, uri=uri)
# For title, we don't have much either. Be pretty generic.
title = ET.SubElement(feed, 'title')
title.text = 'Comments for {hostname}{uri}'.format(
hostname=hostname, uri=uri)
comment0 = None
for comment in comments:
if comment0 is None:
comment0 = comment
entry = ET.SubElement(feed, 'entry')
# We don't use a real date in ID either to help with
# threading.
id = ET.SubElement(entry, 'id')
id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['id'])
title = ET.SubElement(entry, 'title')
title.text = 'Comment #{}'.format(comment['id'])
updated = ET.SubElement(entry, 'updated')
updated.text = '{}Z'.format(datetime.fromtimestamp(
comment['modified'] or comment['created']).isoformat())
author = ET.SubElement(entry, 'author')
name = ET.SubElement(author, 'name')
name.text = comment['author']
ET.SubElement(entry, 'link', {
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['id'])
})
content = ET.SubElement(entry, 'content', {
'type': 'html',
})
content.text = self.isso.render(comment['text'])
if comment['parent']:
ET.SubElement(entry, 'thr:in-reply-to', {
'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['parent']),
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['parent'])
})
# Updated is mandatory. If we have comments, we use the date
# of last modification of the first one (which is the last
# one). Otherwise, we use a fixed date.
updated = ET.Element('updated')
if comment0 is None:
updated.text = '1970-01-01T01:00:00Z'
else:
updated.text = datetime.fromtimestamp(
comment0['modified'] or comment0['created']).isoformat()
updated.text += 'Z'
feed.insert(0, updated)
output = StringIO()
ET.ElementTree(feed).write(output,
encoding='utf-8',
xml_declaration=True)
response = XML(output.getvalue(), 200)
# Add an etag/last-modified value for caching purpose
if comment0 is None:
response.set_etag('empty')
response.last_modified = 0
else:
response.set_etag('{tid}-{id}'.format(**comment0))
response.last_modified = comment0['modified'] or comment0['created']
return response.make_conditional(request)
def preview(self, environment, request):
data = request.get_json()
@ -504,4 +989,53 @@ class API(object):
return JSON({'text': self.isso.render(data["text"])}, 200)
def demo(self, env, req):
return redirect(get_current_url(env) + '/index.html')
return redirect(
get_current_url(env, strip_querystring=True) + '/index.html'
)
def login(self, env, req):
data = req.form
password = self.isso.conf.get("general", "admin_password")
if data['password'] and data['password'] == password:
response = redirect(re.sub(
r'/login$',
'/admin',
get_current_url(env, strip_querystring=True)
))
cookie = functools.partial(dump_cookie,
value=self.isso.sign({"logged": True}),
expires=datetime.now() + timedelta(1))
response.headers.add("Set-Cookie", cookie("admin-session"))
response.headers.add("X-Set-Cookie", cookie("isso-admin-session"))
return response
else:
return render_template('login.html')
def admin(self, env, req):
try:
data = self.isso.unsign(req.cookies.get('admin-session', ''),
max_age=60 * 60 * 24)
except BadSignature:
return render_template('login.html')
if not data or not data['logged']:
return render_template('login.html')
page_size = 100
page = int(req.args.get('page', 0))
order_by = req.args.get('order_by', None)
asc = int(req.args.get('asc', 1))
mode = int(req.args.get('mode', 2))
comments = self.comments.fetchall(mode=mode, page=page,
limit=page_size,
order_by=order_by,
asc=asc)
comments_enriched = []
for comment in list(comments):
comment['hash'] = self.isso.sign(comment['id'])
comments_enriched.append(comment)
comment_mode_count = self.comments.count_modes()
max_page = int(sum(comment_mode_count.values()) / 100)
return render_template('admin.html', comments=comments_enriched,
page=int(page), mode=int(mode),
conf=self.conf, max_page=max_page,
counts=comment_mode_count,
order_by=order_by, asc=asc)

View File

@ -84,6 +84,8 @@ def origin(hosts):
hosts = [urlsplit(h) for h in hosts]
def func(environ):
if 'ISSO_CORS_ORIGIN' in environ:
return environ['ISSO_CORS_ORIGIN']
if not hosts:
return "http://invalid.local"
@ -136,11 +138,14 @@ class CORSMiddleware(object):
headers = Headers(headers)
headers.add("Access-Control-Allow-Origin", self.origin(environ))
headers.add("Access-Control-Allow-Credentials", "true")
headers.add("Access-Control-Allow-Methods", ", ".join(self.methods))
headers.add("Access-Control-Allow-Methods",
", ".join(self.methods))
if self.allowed:
headers.add("Access-Control-Allow-Headers", ", ".join(self.allowed))
headers.add("Access-Control-Allow-Headers",
", ".join(self.allowed))
if self.exposed:
headers.add("Access-Control-Expose-Headers", ", ".join(self.exposed))
headers.add("Access-Control-Expose-Headers",
", ".join(self.exposed))
return start_response(status, headers.to_list(), exc_info)
if environ.get("REQUEST_METHOD") == "OPTIONS":

View File

@ -5,10 +5,13 @@ import sys
from setuptools import setup, find_packages
requires = ['itsdangerous', 'misaka>=1.0,<2.0', 'html5lib==0.9999999']
requires = ['html5lib==0.9999999', 'itsdangerous', 'Jinja2',
'misaka>=1.0,<2.0', 'werkzeug>=0.9']
if (3, 0) <= sys.version_info < (3, 3):
raise SystemExit("Python 3.0, 3.1 and 3.2 are not supported")
if sys.version_info < (2, 7):
raise SystemExit("Python 2 versions < 2.7 are not supported.")
elif (3, 0) <= sys.version_info < (3, 4):
raise SystemExit("Python 3 versions < 3.4 are not supported.")
setup(
name='isso',
@ -27,16 +30,14 @@ setup(
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4"
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6"
],
install_requires=requires,
extras_require={
':python_version=="2.6"': ['argparse', 'ordereddict'],
':python_version=="2.6" or python_version=="2.7"': ['ipaddr>=2.1', 'configparser', 'werkzeug>=0.8'],
':python_version!="2.6" and python_version!="2.7"': ['werkzeug>=0.9']
':python_version=="2.7"': ['ipaddr>=2.1', 'configparser']
},
entry_points={
'console_scripts':

View File

@ -10,6 +10,7 @@ host = http://isso-dev.local/
max-age = 15m
notify = stdout
log-file = /var/log/isso.log
admin_password = strong_default_password_for_isso_admin
[moderation]
enabled = false

View File

@ -10,7 +10,7 @@ dbpath = /tmp/comments.db
# required to dispatch multiple websites, not used otherwise.
name =
# Your website(s). If Isso is unable to connect to at least on site, you'll
# Your website(s). If Isso is unable to connect to at least one site, you'll
# get a warning during startup and comments are most likely non-functional.
#
# You'll need at least one host/website to run Isso. This is due to security
@ -43,7 +43,7 @@ max-age = 15m
# moderated) and deletion links.
notify = stdout
# Log console messages to file instead of standard out.
# Log console messages to file instead of standard output.
log-file =
# adds property "gravatar_image" to json response when true
@ -54,6 +54,10 @@ gravatar = false
# default url for gravatar. {} is where the hash will be placed
gravatar-url = https://www.gravatar.com/avatar/{}?d=identicon
# Admin access password
admin_password = please_choose_a_strong_password
[moderation]
# enable comment moderation queue. This option only affects new comments.
# Comments in modertion queue are not visible to other users until you activate
@ -184,3 +188,15 @@ salt = Eech7co8Ohloopo9Ol6baimi
# strengthening. Arguments have to be in that order, but can be reduced to
# pbkdf2:4096 for example to override the iterations only.
algorithm = pbkdf2
[rss]
# Provide an Atom feed for each comment thread for users to subscribe to.
# The base URL of pages is needed to build the Atom feed. By appending
# the URI, we should get the complete URL to use to access the page
# with the comments. When empty, Atom feeds are disabled.
base =
# Limit the number of elements to return for each thread.
limit = 100

22
tox.ini
View File

@ -1,5 +1,5 @@
[tox]
envlist = py27,py33,py34,py35
envlist = py27,py34,py35,py36
[testenv]
deps =
@ -8,14 +8,6 @@ deps =
commands =
python setup.py nosetests
[testenv:py26]
deps =
argparse
unittest2
ordereddict
configparser
{[testenv]deps}
[testenv:py27]
deps =
configparser
@ -29,15 +21,3 @@ deps=
misaka==1.0.2
passlib==1.5.3
werkzeug==0.8.3
[testenv:squeeze]
basepython=python2.6
deps=
{[testenv:py26]deps}
{[testenv:debian]deps}
[testenv:wheezy]
basepython=python2.7
deps =
{[testenv:py27]deps}
{[testenv:debian]deps}