Merge branch 'master' into feature/gravatar-support
This commit is contained in:
commit
6da91d4ace
23
.travis.yml
23
.travis.yml
@ -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:
|
||||
|
13
CHANGES.rst
13
CHANGES.rst
@ -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)
|
||||
|
@ -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
33
Dockerfile
Normal 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
|
@ -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
|
||||
|
4
Makefile
4
Makefile
@ -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
11
apidoc.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "isso",
|
||||
"description": "a Disqus alternative",
|
||||
"title": "isso API",
|
||||
"order": ["Thread", "Comment"],
|
||||
"template": {
|
||||
"withCompare": false
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -10,7 +10,8 @@
|
||||
VERSION: '{{ release|e }}',
|
||||
COLLAPSE_INDEX: false,
|
||||
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
|
||||
HAS_SOURCE: {{ has_source|lower }}
|
||||
HAS_SOURCE: {{ has_source|lower }},
|
||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
||||
};
|
||||
</script>
|
||||
{%- for scriptfile in script_files %}
|
||||
|
@ -9,4 +9,3 @@ class IssoTranslator(HTMLTranslator):
|
||||
if self.section_level == 1:
|
||||
raise nodes.SkipNode
|
||||
HTMLTranslator.visit_title(self, node)
|
||||
|
||||
|
24
docs/conf.py
24
docs/conf.py
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
--------
|
||||
|
||||
|
@ -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.
|
||||
|
@ -213,7 +213,7 @@ Next, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer):
|
||||
WSGIServer(application).run()
|
||||
|
||||
`Openshift <http://openshift.com>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
With `Isso Openshift Deployment Kit`_, Isso can be installed on Open
|
||||
Shift with just one click. Make sure you already have installed ``rhc``
|
||||
(`instructions`_) and completed the setup.
|
||||
|
@ -10,7 +10,7 @@ Debian/Ubuntu, Gentoo, Archlinux or Fedora, you can use
|
||||
:local:
|
||||
:depth: 1
|
||||
|
||||
.. _install-interludium:
|
||||
.. _install-interludium:
|
||||
|
||||
Interludium: Python is not PHP
|
||||
------------------------------
|
||||
@ -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
|
||||
|
@ -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
|
||||
-----------------------------------
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
134
isso/css/admin.css
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 = ?',
|
||||
|
@ -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]
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
2
isso/img/isso.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 11 KiB |
@ -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
|
||||
};
|
||||
});
|
||||
|
@ -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");
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -3,6 +3,8 @@ define({
|
||||
"postbox-author": "Име/псевдоним (незадължително)",
|
||||
"postbox-email": "Ел. поща (незадължително)",
|
||||
"postbox-website": "Уебсайт (незадължително)",
|
||||
"postbox-preview": "преглед",
|
||||
"postbox-edit": "Редактиране",
|
||||
"postbox-submit": "Публикуване",
|
||||
"num-comments": "1 коментар\n{{ n }} коментара",
|
||||
"no-comments": "Все още няма коментари",
|
||||
|
@ -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
32
isso/js/app/i18n/da.js
Normal 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"
|
||||
});
|
@ -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",
|
||||
|
@ -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": "Δεν υπάρχουν σχόλια",
|
||||
|
@ -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",
|
||||
|
@ -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ŭ",
|
||||
|
@ -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",
|
||||
|
@ -3,6 +3,8 @@ define({
|
||||
"postbox-author": "اسم (اختیاری)",
|
||||
"postbox-email": "ایمیل (اختیاری)",
|
||||
"postbox-website": "سایت (اختیاری)",
|
||||
"postbox-preview": "پیشنمایش",
|
||||
"postbox-edit": "ویرایش",
|
||||
"postbox-submit": "ارسال",
|
||||
|
||||
"num-comments": "یک نظر\n{{ n }} نظر",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
29
isso/js/app/i18n/hu.js
Normal 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"
|
||||
});
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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": "Пока нет комментариев",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 }} 年前"
|
||||
});
|
||||
|
@ -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 }} 年前"
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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'))
|
||||
|
@ -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 = {
|
||||
|
@ -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>');
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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
253
isso/templates/admin.html
Normal 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"> •</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
30
isso/templates/login.html
Normal 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>
|
@ -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'))
|
||||
|
@ -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"><p><em>Second</em></p></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"><p>First</p></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)
|
||||
|
@ -1,10 +1,6 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
try:
|
||||
import unittest2 as unittest
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
import unittest
|
||||
import io
|
||||
|
||||
from isso import config
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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>'])
|
||||
|
@ -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")
|
||||
|
@ -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.'))
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -1,10 +1,6 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
try:
|
||||
import unittest2 as unittest
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
import unittest
|
||||
|
||||
from isso import wsgi
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 comment’s 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 comment’s 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 comments’s author’s name or `null`.
|
||||
@apiSuccess {string} website
|
||||
The comment’s author’s website or `null`.
|
||||
@apiSuccess {string} hash
|
||||
A hash uniquely identifying the comment’s 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 comment’s raw text.
|
||||
@apiParam {string} [author]
|
||||
The comment’s author’s name.
|
||||
@apiParam {string} [email]
|
||||
The comment’s author’s email address.
|
||||
@apiParam {string} [website]
|
||||
The comment’s author’s website’s url.
|
||||
@apiParam {number} [parent]
|
||||
The parent comment’s 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": "<p>Stop saying that! <em>isso</em>!</p>",
|
||||
"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": " <p>I want to use MySQL</p>",
|
||||
"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 comment’s author’s name.
|
||||
@apiParam {string} [webiste]
|
||||
The modified comment’s author’s 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": "<p>I see your point. However, I still disagree.</p>",
|
||||
"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:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
if (confirm('Delete: Are you sure?')) {
|
||||
xhr = new XMLHttpRequest;
|
||||
xhr.open('POST', window.location.href);
|
||||
xhr.send(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
@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": "<p>Hello, World!</p>",
|
||||
"total_replies": 1,
|
||||
"hidden_replies": 0,
|
||||
"dislikes": 2,
|
||||
"modified": null,
|
||||
"mode": 1,
|
||||
"replies": [
|
||||
{
|
||||
"website": null,
|
||||
"author": null,
|
||||
"parent": 1,
|
||||
"created": 1464818460.769638,
|
||||
"text": "<p>Hi, now some Markdown: <em>Italic</em>, <strong>bold</strong>, <code>monospace</code>.</p>",
|
||||
"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": "<p>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?</p>",
|
||||
"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)
|
||||
|
11
isso/wsgi.py
11
isso/wsgi.py
@ -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":
|
||||
|
19
setup.py
19
setup.py
@ -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':
|
||||
|
@ -10,6 +10,7 @@ host = http://isso-dev.local/
|
||||
max-age = 15m
|
||||
notify = stdout
|
||||
log-file = /var/log/isso.log
|
||||
admin_password = strong_default_password_for_isso_admin
|
||||
|
||||
[moderation]
|
||||
enabled = false
|
||||
|
@ -10,7 +10,7 @@ dbpath = /tmp/comments.db
|
||||
# required to dispatch multiple websites, not used otherwise.
|
||||
name =
|
||||
|
||||
# Your website(s). If Isso is unable to connect to at least on site, you'll
|
||||
# Your website(s). If Isso is unable to connect to at least one site, you'll
|
||||
# get a warning during startup and comments are most likely non-functional.
|
||||
#
|
||||
# You'll need at least one host/website to run Isso. This is due to security
|
||||
@ -43,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
22
tox.ini
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user