Merge remote-tracking branch 'posativ/master' into reply_notification

Conflicts:
	isso/css/isso.css
	isso/ext/notifications.py
	isso/js/app/text/postbox.jade
	isso/views/comments.py
This commit is contained in:
Pelle Nilsson 2018-04-29 14:48:13 +02:00
commit f6f61c547a
52 changed files with 619 additions and 96 deletions

View File

@ -4,7 +4,8 @@ Changelog for Isso
0.10.7 (unreleased) 0.10.7 (unreleased)
------------------- -------------------
- Fix Chinese translation - 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 Danish translation
- Add Hungarian translation - Add Hungarian translation
- Add Persian translation - Add Persian translation
@ -12,7 +13,15 @@ Changelog for Isso
- Add links highlighting in comments - Add links highlighting in comments
- Add apidoc - Add apidoc
- Add rc.d script for FreeBSD - Add rc.d script for FreeBSD
- Some tests/travis/documentation improvements and fixes - Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable
- Add preview button
- Add Atom feed at /feed?uri={thread-id}
- Add optionnal gravatar support
- Add nofollow noopener on links inside comments
- Add Dockerfile
- Upgraded to Misaka 2
- Some tests/travis/documentation improvements and fixes + pep8
- Improvement on german translation
0.10.6 (2016-09-22) 0.10.6 (2016-09-22)

View File

@ -51,12 +51,46 @@ In chronological order:
* Added configuration to require email addresses (no validation) * Added configuration to require email addresses (no validation)
* Fix Vagrantfile * Fix Vagrantfile
* Benoît Latinier <benoit@latinier.fr> * Benoît Latinier @blatinier <benoit@latinier.fr>
* Fix thread discovery * Fix thread discovery
* Added mandatory author * Added mandatory author
* Added admin interface
* Ivan Pantic <ivanpantic82@gmail.com> * Ivan Pantic <ivanpantic82@gmail.com>
* Added vote levels * 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
* @benjhess
* Optionnal gravatar support
* Steffen Prince @sprin
* Upgrade to Misaka 2
* [Your name or handle] <[email or website]> * [Your name or handle] <[email or website]>
* [Brief summary of your changes] * [Brief summary of your changes]

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# First, compile JS stuff
FROM node
WORKDIR /src/
COPY . .
RUN npm install -g requirejs uglify-js jade bower
RUN make init js
# Second, create virtualenv
FROM python:3-stretch
WORKDIR /src/
COPY --from=0 /src .
RUN apt-get -qqy update && apt-get -qqy install python3-dev sqlite3
RUN python3 -m venv /isso \
&& . /isso/bin/activate \
&& python setup.py install \
&& pip install gunicorn
# Third, create final repository
FROM python:3-slim-stretch
WORKDIR /isso/
COPY --from=1 /isso .
# Configuration
VOLUME /db /config
EXPOSE 8080
ENV ISSO_SETTINGS /config/isso.cfg
CMD ["/isso/bin/gunicorn", "-b", "0.0.0.0:8080", "-w", "4", "--preload", "isso.run"]
# Example of use:
#
# docker build -t isso .
# docker run -it --rm -v /opt/isso:/config -v /opt/isso:/db -v $PWD:$PWD isso /isso/bin/isso -c \$ISSO_SETTINGS import disqus.xml
# docker run -d --rm --name isso -p 8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso

View File

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

View File

@ -7,6 +7,7 @@ preferably in the script tag which embeds the JS:
.. code-block:: html .. code-block:: html
<script data-isso="/prefix/" <script data-isso="/prefix/"
data-isso-id="thread-id"
data-isso-css="true" data-isso-css="true"
data-isso-lang="ru" data-isso-lang="ru"
data-isso-reply-to-self="false" 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-bg="#f0f0f0"
data-isso-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..." data-isso-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
data-isso-vote="true" data-isso-vote="true"
data-vote-levels="" data-isso-vote-levels=""
data-isso-feed="false"
src="/prefix/js/embed.js"></script> src="/prefix/js/embed.js"></script>
Furthermore you can override the automatic title detection inside 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 .. 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 data-isso
--------- ---------
@ -106,6 +108,14 @@ scheme is based in `this color palette <http://colrd.com/palette/19308/>`_.
Multiple colors must be separated by space. If you use less than eight colors Multiple colors must be separated by space. If you use less than eight colors
and not a multiple of 2, the color distribution is not even. and not a multiple of 2, the color distribution is not even.
data-isso-gravatar
------------------
Uses gravatar images instead of generating svg images. You have to set
"data-isso-avatar" to **false** when you want to use this. Otherwise
both the gravatar and avatar svg image will show up. Please also set
option "gravatar" to **true** in the server configuration...
data-isso-vote data-isso-vote
-------------- --------------
@ -124,3 +134,10 @@ For example, the value `"-5,5"` will cause each `isso-comment` to be given one o
- `isso-vote-level-2` for scores of `5` and greater - `isso-vote-level-2` for scores of `5` and greater
These classes can then be used to customize the appearance of comments (eg. put a star on popular comments) These classes can then be used to customize the appearance of comments (eg. put a star on popular comments)
data-isso-feed
--------------
Enable or disable the addition of a link to the feed for the comment
thread. The link will only be valid if the appropriate setting, in
``[rss]`` section, is also enabled server-side.

View File

@ -91,6 +91,18 @@ notify
log-file log-file
Log console messages to file instead of standard out. Log console messages to file instead of standard out.
gravatar
When set to ``true`` this will add the property "gravatar_image"
containing the link to a gravatar image to every comment. If a comment
does not contain an email address, gravatar will render a random icon.
This is only true when using the default value for "gravatar-url"
which contains the query string param ``d=identicon`` ...
gravatar-url
Url for gravatar images. The "{}" is where the email hash will be placed.
Defaults to "https://www.gravatar.com/avatar/{}?d=identicon"
.. _CORS: https://developer.mozilla.org/en/docs/HTTP/Access_control_CORS .. _CORS: https://developer.mozilla.org/en/docs/HTTP/Access_control_CORS
@ -108,7 +120,7 @@ Enable moderation queue and handling of comments still in moderation queue
enabled enabled
enable comment moderation queue. This option only affects new comments. 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. activate them.
purge-after purge-after
@ -308,6 +320,27 @@ algorithm
Arguments have to be in that order, but can be reduced to `pbkdf2:4096` Arguments have to be in that order, but can be reduced to `pbkdf2:4096`
for example to override the iterations only. 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 Appendum
-------- --------

View File

@ -185,3 +185,16 @@ uri :
returns an integer returns an integer
Get Atom feed
-------------
Get an Atom feed of comments for thread `uri`:
.. code-block:: text
GET /feed?uri=%2Fhello-world%2F
uri :
URI to get comments for, required.
Returns an XML document as the Atom feed.

View File

@ -39,10 +39,10 @@ package manager.
.. code-block:: sh .. code-block:: sh
# for Debian/Ubuntu # for Debian/Ubuntu
~> sudo apt-get install python-setuptools python-virtualenv ~> sudo apt-get install python-setuptools python-virtualenv python-dev
# Fedora/Red Hat # 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 The next steps should be done as regular user, not as root (although possible
but not recommended): but not recommended):
@ -149,7 +149,18 @@ Prebuilt Packages
* Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr * Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr
repository. Built from Pypi, includes a systemctl unit script. 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 Install from Source
------------------- -------------------

View File

@ -1,6 +1,12 @@
Troubleshooting 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 pkg_ressources.DistributionNotFound
----------------------------------- -----------------------------------

View File

@ -15,6 +15,14 @@
color: #555; color: #555;
font-weight: bold; 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 { #isso-thread .textarea {
min-height: 58px; min-height: 58px;
outline: 0; outline: 0;
@ -23,31 +31,26 @@
color: #757575; color: #757575;
} }
.isso-comment { #isso-root .isso-comment {
max-width: 68em; max-width: 68em;
padding-top: 0.95em; padding-top: 0.95em;
margin: 0.95em auto; 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 { .isso-follow-up .isso-comment {
border-top: 1px solid rgba(0, 0, 0, 0.1); border-top: 1px solid rgba(0, 0, 0, 0.1);
} }
.isso-comment > div.avatar, .isso-comment > div.avatar {
.isso-postbox > .avatar {
display: block; display: block;
float: left; float: left;
width: 7%; width: 7%;
margin: 3px 15px 0 0; margin: 3px 15px 0 0;
} }
.isso-postbox > .avatar { .isso-comment > div.avatar > svg {
float: left;
margin: 5px 10px 0 5px;
width: 48px;
height: 48px;
overflow: hidden;
}
.isso-comment > div.avatar > svg,
.isso-postbox > .avatar > svg {
max-width: 48px; max-width: 48px;
max-height: 48px; max-height: 48px;
border: 1px solid rgba(0, 0, 0, 0.2); border: 1px solid rgba(0, 0, 0, 0.2);
@ -90,7 +93,8 @@
font-weight: bold; font-weight: bold;
color: #555; 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; margin-top: 0.2em;
} }
.isso-comment > div.text-wrapper > div.text p { .isso-comment > div.text-wrapper > div.text p {
@ -108,7 +112,8 @@
font-size: 130%; font-size: 130%;
font-weight: bold; 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%; width: 100%;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
border-radius: 2px; border-radius: 2px;
@ -119,10 +124,12 @@
color: gray !important; color: gray !important;
clear: left; clear: left;
} }
.isso-feedlink,
.isso-comment > div.text-wrapper > .isso-comment-footer a { .isso-comment > div.text-wrapper > .isso-comment-footer a {
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
} }
.isso-feedlink:hover,
.isso-comment > div.text-wrapper > .isso-comment-footer a:hover { .isso-comment > div.text-wrapper > .isso-comment-footer a:hover {
color: #111111 !important; color: #111111 !important;
text-shadow: #aaaaaa 0 0 1px !important; text-shadow: #aaaaaa 0 0 1px !important;
@ -152,6 +159,7 @@
.isso-postbox { .isso-postbox {
max-width: 68em; max-width: 68em;
margin: 0 auto 2em; margin: 0 auto 2em;
clear: right;
} }
.isso-postbox > .form-wrapper { .isso-postbox > .form-wrapper {
display: block; display: block;
@ -161,7 +169,8 @@
.isso-postbox > .form-wrapper > .auth-section .post-action { .isso-postbox > .form-wrapper > .auth-section .post-action {
display: block; display: block;
} }
.isso-postbox > .form-wrapper .textarea { .isso-postbox > .form-wrapper .textarea,
.isso-postbox > .form-wrapper .preview {
margin: 0 0 .3em; margin: 0 0 .3em;
padding: .4em .8em; padding: .4em .8em;
border-radius: 3px; border-radius: 3px;
@ -191,7 +200,7 @@
.isso-postbox > .form-wrapper > .auth-section .post-action { .isso-postbox > .form-wrapper > .auth-section .post-action {
display: inline-block; display: inline-block;
float: right; float: right;
margin: 0; margin: 0 0 0 5px;
} }
.isso-postbox > .form-wrapper > .auth-section .post-action > input { .isso-postbox > .form-wrapper > .auth-section .post-action > input {
padding: calc(.3em - 1px); padding: calc(.3em - 1px);
@ -209,6 +218,27 @@
.isso-postbox > .form-wrapper > .auth-section .post-action > input:active { .isso-postbox > .form-wrapper > .auth-section .post-action > input:active {
background-color: #BBB; background-color: #BBB;
} }
.isso-postbox > .form-wrapper .preview,
.isso-postbox > .form-wrapper input[name="edit"],
.isso-postbox.preview-mode > .form-wrapper input[name="preview"],
.isso-postbox.preview-mode > .form-wrapper .textarea {
display: none;
}
.isso-postbox.preview-mode > .form-wrapper .preview {
display: block;
}
.isso-postbox.preview-mode > .form-wrapper input[name="edit"] {
display: inline;
}
.isso-postbox > .form-wrapper .preview {
background-color: #f8f8f8;
background: repeating-linear-gradient(
-45deg,
#f8f8f8,
#f8f8f8 10px,
#fff 10px,
#fff 20px
);
.isso-postbox > .form-wrapper > .notification-section { .isso-postbox > .form-wrapper > .notification-section {
display: none; display: none;
padding-bottom: 10px; padding-bottom: 10px;
@ -222,9 +252,4 @@
.isso-postbox > .form-wrapper > .auth-section .input-wrapper input { .isso-postbox > .form-wrapper > .auth-section .input-wrapper input {
width: 100%; width: 100%;
} }
.isso-postbox > .form-wrapper > .auth-section .post-action {
display: block;
float: none;
text-align: right;
}
} }

View File

@ -173,7 +173,8 @@ class Comments:
for item in rv: for item in rv:
yield dict(zip(fields_comments + fields_threads, item)) yield dict(zip(fields_comments + fields_threads, item))
def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None): 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`. Return comments for :param:`uri` with :param:`mode`.
""" """
@ -195,7 +196,8 @@ class Comments:
order_by = 'id' order_by = 'id'
sql.append('ORDER BY ') sql.append('ORDER BY ')
sql.append(order_by) sql.append(order_by)
sql.append(' ASC') if not asc:
sql.append(' DESC')
if limit: if limit:
sql.append('LIMIT ?') sql.append('LIMIT ?')

View File

@ -70,6 +70,12 @@ class SMTP(object):
self.isso = isso self.isso = isso
self.conf = isso.conf.section("smtp") 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 # test SMTP connectivity
try: try:
@ -118,7 +124,7 @@ class SMTP(object):
rv.write("---\n") rv.write("---\n")
if admin: if admin:
uri = local("host") + "/id/%i" % comment["id"] uri = self.general_host + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"]) key = self.isso.sign(comment["id"])
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
@ -127,7 +133,7 @@ class SMTP(object):
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
else: else:
uri = local("host") + "/id/%i" % comment_parent["id"] uri = self.general_host + "/id/%i" % comment_parent["id"]
key = self.isso.sign(('unsubscribe', comment_parent["id"])) key = self.isso.sign(('unsubscribe', comment_parent["id"]))
rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + key)) rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + key))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,8 @@ define({
"postbox-author": "Name (optional)", "postbox-author": "Name (optional)",
"postbox-email": "Email (optional)", "postbox-email": "Email (optional)",
"postbox-website": "Website (optional)", "postbox-website": "Website (optional)",
"postbox-preview": "Vorschau",
"postbox-edit": "Bearbeiten",
"postbox-submit": "Abschicken", "postbox-submit": "Abschicken",
"num-comments": "1 Kommentar\n{{ n }} Kommentare", "num-comments": "1 Kommentar\n{{ n }} Kommentare",
"no-comments": "Bisher keine Kommentare", "no-comments": "Bisher keine Kommentare",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,8 @@ define({
"postbox-author": "Név (nem kötelező)", "postbox-author": "Név (nem kötelező)",
"postbox-email": "Email (nem kötelező)", "postbox-email": "Email (nem kötelező)",
"postbox-website": "Website (nem kötelező)", "postbox-website": "Website (nem kötelező)",
"postbox-preview": "Előnézet",
"postbox-edit": "Szerekesztés",
"postbox-submit": "Elküld", "postbox-submit": "Elküld",
"num-comments": "Egy hozzászólás\n{{ n }} hozzászólás", "num-comments": "Egy hozzászólás\n{{ n }} hozzászólás",
"no-comments": "Eddig nincs hozzászólás", "no-comments": "Eddig nincs hozzászólás",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
el = $.htmlify(jade.render("postbox", { el = $.htmlify(jade.render("postbox", {
"author": JSON.parse(localStorage.getItem("author")), "author": JSON.parse(localStorage.getItem("author")),
"email": JSON.parse(localStorage.getItem("email")), "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) // callback on success (e.g. to toggle the reply button)
@ -62,9 +63,27 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("[name='author']", el).placeholder.replace(/ \(.*\)/, ""); $("[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. // submit form, initialize optional fields with `null` and reset form.
// If replied to a comment, remove form completely. // If replied to a comment, remove form completely.
$("[type=submit]", el).on("click", function() { $("[type=submit]", el).on("click", function() {
edit();
if (! el.validate()) { if (! el.validate()) {
return; return;
} }
@ -240,7 +259,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("a.edit", footer).toggle("click", $("a.edit", footer).toggle("click",
function(toggler) { function(toggler) {
var edit = $("a.edit", footer); var edit = $("a.edit", footer);
var avatar = config["avatar"] ? $(".avatar", el, false)[0] : null; var avatar = config["avatar"] || config["gravatar"] ? $(".avatar", el, false)[0] : null;
edit.textContent = i18n.translate("comment-save"); edit.textContent = i18n.translate("comment-save");
edit.insertAfter($.new("a.cancel", i18n.translate("comment-cancel"))).on("click", function() { edit.insertAfter($.new("a.cancel", i18n.translate("comment-cancel"))).on("click", function() {
@ -268,7 +287,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
}, },
function(toggler) { function(toggler) {
var textarea = $(".textarea", text); var textarea = $(".textarea", text);
var avatar = config["avatar"] ? $(".avatar", el, false)[0] : null; var avatar = config["avatar"] || config["gravatar"] ? $(".avatar", el, false)[0] : null;
if (! toggler.canceled && textarea !== null) { if (! toggler.canceled && textarea !== null) {
if (utils.text(textarea.innerHTML).length < 3) { if (utils.text(textarea.innerHTML).length < 3) {

View File

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

View File

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

View File

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

View File

@ -27,6 +27,13 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
return console.log("abort, #isso-thread is missing"); 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('h4'));
$("#isso-thread").append(new isso.Postbox(null)); $("#isso-thread").append(new isso.Postbox(null));
$("#isso-thread").append('<div id="isso-root"></div>'); $("#isso-thread").append('<div id="isso-root"></div>');

View File

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

View File

@ -55,7 +55,7 @@ class Progress(object):
if time() - self.last > 0.2: if time() - self.last > 0.2:
sys.stdout.write("\r{0}".format(" " * cols)) sys.stdout.write("\r{0}".format(" " * cols))
sys.stdout.write("\r[{0:.0%}] {1}".format(i/self.end, message)) sys.stdout.write("\r[{0:.0%}] {1}".format(i / self.end, message))
sys.stdout.flush() sys.stdout.flush()
self.last = time() self.last = time()

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
import os import os
import json import json
import re
import tempfile import tempfile
import unittest import unittest
@ -32,6 +33,7 @@ class TestComments(unittest.TestCase):
conf.set("general", "dbpath", self.path) conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none") conf.set("hash", "algorithm", "none")
self.conf = conf
class App(Isso, core.Mixin): class App(Isso, core.Mixin):
pass pass
@ -132,13 +134,13 @@ class TestComments(unittest.TestCase):
self.assertFalse(verify({"text": text})) self.assertFalse(verify({"text": text}))
# email/website length # email/website length
self.assertTrue(verify({"text": "...", "email": "*"*254})) self.assertTrue(verify({"text": "...", "email": "*" * 254}))
self.assertTrue( self.assertTrue(
verify({"text": "...", "website": "google.de/" + "a"*128})) verify({"text": "...", "website": "google.de/" + "a" * 128}))
self.assertFalse(verify({"text": "...", "email": "*"*1024})) self.assertFalse(verify({"text": "...", "email": "*" * 1024}))
self.assertFalse( self.assertFalse(
verify({"text": "...", "website": "google.de/" + "*"*1024})) verify({"text": "...", "website": "google.de/" + "*" * 1024}))
# valid website url # valid website url
self.assertTrue(comments.isurl("example.tld")) self.assertTrue(comments.isurl("example.tld"))
@ -320,10 +322,41 @@ class TestComments(unittest.TestCase):
rv = loads(rv.data) rv = loads(rv.data)
for key in comments.API.FIELDS: for key in comments.API.FIELDS:
rv.pop(key) if key in rv:
rv.pop(key)
self.assertListEqual(list(rv.keys()), []) self.assertListEqual(list(rv.keys()), [])
def testNoFeed(self):
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
self.assertEqual(rv.status_code, 404)
def testFeedEmpty(self):
self.conf.set("rss", "base", "https://example.org")
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers['ETag'], '"empty"')
data = rv.data.decode('utf-8')
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>1970-01-01T01:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/nothing</id><title>Comments for example.org/path/nothing</title></feed>""")
def testFeed(self):
self.conf.set("rss", "base", "https://example.org")
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
self.post('/new?uri=%2Fpath%2F',
data=json.dumps({'text': '*Second*', 'parent': 1}))
rv = self.get('/feed?uri=%2Fpath%2F')
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers['ETag'], '"1-2"')
data = rv.data.decode('utf-8')
data = re.sub('[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+Z',
'2018-04-01T10:00:00Z', data)
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>2018-04-01T10:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/</id><title>Comments for example.org/path/</title><entry><id>tag:example.org,2018:/isso/1/2</id><title>Comment #2</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-2" /><content type="html">&lt;p&gt;&lt;em&gt;Second&lt;/em&gt;&lt;/p&gt;</content><thr:in-reply-to href="https://example.org/path/#isso-1" ref="tag:example.org,2018:/isso/1/1" /></entry><entry><id>tag:example.org,2018:/isso/1/1</id><title>Comment #1</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-1" /><content type="html">&lt;p&gt;First&lt;/p&gt;</content></entry></feed>""")
def testCounts(self): def testCounts(self):
self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404) self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404)

View File

@ -29,7 +29,7 @@ class TestHTML(unittest.TestCase):
self.assertEqual(convert(input), expected) self.assertEqual(convert(input), expected)
def test_github_flavoured_markdown(self): def test_github_flavoured_markdown(self):
convert = html.Markdown(extensions=("fenced_code", )) convert = html.Markdown(extensions=("fenced-code", ))
# without lang # without lang
_in = textwrap.dedent("""\ _in = textwrap.dedent("""\
@ -65,13 +65,17 @@ class TestHTML(unittest.TestCase):
examples = [ examples = [
('Look: <img src="..." />', 'Look: '), ('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>'), ('<a href="sms:+1234567890">Ha</a>', '<a>Ha</a>'),
('<p style="visibility: hidden;">Test</p>', '<p>Test</p>'), ('<p style="visibility: hidden;">Test</p>', '<p>Test</p>'),
('<script>alert("Onoe")</script>', 'alert("Onoe")')] ('<script>alert("Onoe")</script>', 'alert("Onoe")')]
for (input, expected) in examples: 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") @unittest.skipIf(html.HTML5LIB_VERSION <= html.HTML5LIB_SIMPLETREE, "backport")
def test_sanitizer_extensions(self): def test_sanitizer_extensions(self):
@ -92,5 +96,6 @@ class TestHTML(unittest.TestCase):
} }
}) })
renderer = html.Markup(conf.section("markup")).render renderer = html.Markup(conf.section("markup")).render
self.assertEqual(renderer("http://example.org/ and sms:+1234567890"), self.assertIn(renderer("http://example.org/ and sms:+1234567890"),
'<p><a href="http://example.org/">http://example.org/</a> and sms:+1234567890</p>') ['<p><a href="http://example.org/" rel="nofollow noopener">http://example.org/</a> and sms:+1234567890</p>',
'<p><a rel="nofollow noopener" href="http://example.org/">http://example.org/</a> and sms:+1234567890</p>'])

View File

@ -36,7 +36,7 @@ def anonymize(remote_addr):
ipv6 = ipaddress.IPv6Address(remote_addr) ipv6 = ipaddress.IPv6Address(remote_addr)
if ipv6.ipv4_mapped is not None: if ipv6.ipv4_mapped is not None:
return anonymize(ipv6.ipv4_mapped) return anonymize(ipv6.ipv4_mapped)
return u'' + ipv6.exploded.rsplit(':', 5)[0] + ':' + ':'.join(['0000']*5) return u'' + ipv6.exploded.rsplit(':', 5)[0] + ':' + ':'.join(['0000'] * 5)
except ipaddress.AddressValueError: except ipaddress.AddressValueError:
return u'0.0.0.0' return u'0.0.0.0'
@ -89,11 +89,11 @@ class Bloomfilter:
def add(self, key): def add(self, key):
for i in self.get_probes(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 self.elements += 1
def __contains__(self, key): 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): def __len__(self):
return self.elements return self.elements
@ -132,3 +132,10 @@ class JSONResponse(Response):
kwargs["content_type"] = "application/json" kwargs["content_type"] = "application/json"
super(JSONResponse, self).__init__( super(JSONResponse, self).__init__(
json.dumps(obj).encode("utf-8"), *args, **kwargs) json.dumps(obj).encode("utf-8"), *args, **kwargs)
class XMLResponse(Response):
def __init__(self, obj, *args, **kwargs):
kwargs["content_type"] = "text/xml"
super(XMLResponse, self).__init__(
obj, *args, **kwargs)

View File

@ -110,3 +110,4 @@ def new(conf):
sha1 = Hash(func="sha1").uhash sha1 = Hash(func="sha1").uhash
md5 = Hash(func="md5").uhash

View File

@ -2,7 +2,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import operator
import pkg_resources import pkg_resources
from distutils.version import LooseVersion as Version from distutils.version import LooseVersion as Version
@ -10,8 +9,6 @@ from distutils.version import LooseVersion as Version
HTML5LIB_VERSION = Version(pkg_resources.get_distribution("html5lib").version) HTML5LIB_VERSION = Version(pkg_resources.get_distribution("html5lib").version)
HTML5LIB_SIMPLETREE = Version("0.95") HTML5LIB_SIMPLETREE = Version("0.95")
from isso.compat import reduce
import html5lib import html5lib
from html5lib.sanitizer import HTMLSanitizer from html5lib.sanitizer import HTMLSanitizer
from html5lib.serializer import HTMLSerializer from html5lib.serializer import HTMLSerializer
@ -23,7 +20,8 @@ def Sanitizer(elements, attributes):
class Inner(HTMLSanitizer): class Inner(HTMLSanitizer):
# attributes found in Sundown's HTML serializer [1] except for <img> tag, # attributes found in Sundown's HTML serializer [1]
# except for <img> tag,
# because images are not generated anyways. # because images are not generated anyways.
# #
# [1] https://github.com/vmg/sundown/blob/master/html/html.c # [1] https://github.com/vmg/sundown/blob/master/html/html.c
@ -50,6 +48,11 @@ def sanitize(tokenizer, document):
if HTML5LIB_VERSION > HTML5LIB_SIMPLETREE: if HTML5LIB_VERSION > HTML5LIB_SIMPLETREE:
builder = "etree" 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: else:
builder = "simpletree" builder = "simpletree"
@ -60,14 +63,14 @@ def sanitize(tokenizer, document):
return serializer.render(stream) return serializer.render(stream)
def Markdown(extensions=("strikethrough", "superscript", "autolink")): def Markdown(extensions=("strikethrough", "superscript", "autolink",
"fenced-code")):
flags = reduce(operator.xor, map( renderer = Unofficial()
lambda ext: getattr(misaka, 'EXT_' + ext.upper()), extensions), 0) md = misaka.Markdown(renderer, extensions=extensions)
md = misaka.Markdown(Unofficial(), extensions=flags)
def inner(text): def inner(text):
rv = md.render(text).rstrip("\n") rv = md(text).rstrip("\n")
if rv.startswith("<p>") or rv.endswith("</p>"): if rv.startswith("<p>") or rv.endswith("</p>"):
return rv return rv
return "<p>" + rv + "</p>" return "<p>" + rv + "</p>"
@ -83,7 +86,7 @@ class Unofficial(misaka.HtmlRenderer):
to <code class="$lang">, compatible with Highlight.js. to <code class="$lang">, compatible with Highlight.js.
""" """
def block_code(self, text, lang): def blockcode(self, text, lang):
lang = ' class="{0}"'.format(lang) if lang else '' lang = ' class="{0}"'.format(lang) if lang else ''
return "<pre><code{1}>{0}</code></pre>\n".format(text, lang) return "<pre><code{1}>{0}</code></pre>\n".format(text, lang)

View File

@ -9,6 +9,7 @@ import functools
from datetime import datetime, timedelta from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET
from werkzeug.http import dump_cookie from werkzeug.http import dump_cookie
from werkzeug.wsgi import get_current_url from werkzeug.wsgi import get_current_url
@ -20,10 +21,21 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str from isso.compat import text_type as str
from isso import utils, local 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) render_template)
from isso.views import requires from isso.views import requires
from isso.utils.hash import sha1 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* # from Django appearently, looks good to me *duck*
__url_re = re.compile( __url_re = re.compile(
@ -81,7 +93,7 @@ def xhr(func):
class API(object): class API(object):
FIELDS = set(['id', 'parent', 'text', 'author', 'website', FIELDS = set(['id', 'parent', 'text', 'author', 'website',
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'notification']) 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification'])
# comment fields, that can be submitted # comment fields, that can be submitted
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification']) ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification'])
@ -91,6 +103,7 @@ class API(object):
('new', ('POST', '/new')), ('new', ('POST', '/new')),
('count', ('GET', '/count')), ('count', ('GET', '/count')),
('counts', ('POST', '/count')), ('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('view', ('GET', '/id/<int:id>')), ('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')), ('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')), ('delete', ('DELETE', '/id/<int:id>')),
@ -292,6 +305,8 @@ class API(object):
self.cache.set( self.cache.set(
'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash']) 'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
rv = self._add_gravatar_image(rv)
for key in set(rv.keys()) - API.FIELDS: for key in set(rv.keys()) - API.FIELDS:
rv.pop(key) rv.pop(key)
@ -779,6 +794,18 @@ class API(object):
return JSON(rv, 200) return JSON(rv, 200)
def _add_gravatar_image(self, item):
if not self.conf.getboolean('gravatar'):
return item
email = item['email'] or ""
email_md5_hash = md5(email)
gravatar_url = self.conf.get('gravatar-url')
item['gravatar_image'] = gravatar_url.format(email_md5_hash)
return item
def _process_fetched_list(self, fetched_list, plain=False): def _process_fetched_list(self, fetched_list, plain=False):
for item in fetched_list: for item in fetched_list:
@ -791,6 +818,8 @@ class API(object):
item['hash'] = val item['hash'] = val
item = self._add_gravatar_image(item)
for key in set(item.keys()) - API.FIELDS: for key in set(item.keys()) - API.FIELDS:
item.pop(key) item.pop(key)
@ -885,7 +914,6 @@ class API(object):
@apiSuccessExample Counts of 5 threads: @apiSuccessExample Counts of 5 threads:
[2, 18, 4, 0, 3] [2, 18, 4, 0, 3]
""" """
def counts(self, environ, request): def counts(self, environ, request):
data = request.get_json() data = request.get_json()
@ -895,6 +923,125 @@ class API(object):
return JSON(self.comments.count(*data), 200) 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): def preview(self, environment, request):
data = request.get_json() data = request.get_json()
@ -904,14 +1051,19 @@ class API(object):
return JSON({'text': self.isso.render(data["text"])}, 200) return JSON({'text': self.isso.render(data["text"])}, 200)
def demo(self, env, req): 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): def login(self, env, req):
data = req.form data = req.form
password = self.isso.conf.get("general", "admin_password") password = self.isso.conf.get("general", "admin_password")
if data['password'] and data['password'] == password: if data['password'] and data['password'] == password:
response = redirect(get_current_url( response = redirect(re.sub(
env, host_only=True) + '/admin') r'/login$',
'/admin',
get_current_url(env, strip_querystring=True)
))
cookie = functools.partial(dump_cookie, cookie = functools.partial(dump_cookie,
value=self.isso.sign({"logged": True}), value=self.isso.sign({"logged": True}),
expires=datetime.now() + timedelta(1)) expires=datetime.now() + timedelta(1))

View File

@ -30,7 +30,7 @@ def host(environ): # pragma: no cover
of http://www.python.org/dev/peps/pep-0333/#url-reconstruction of http://www.python.org/dev/peps/pep-0333/#url-reconstruction
""" """
url = environ['wsgi.url_scheme']+'://' url = environ['wsgi.url_scheme'] + '://'
if environ.get('HTTP_HOST'): if environ.get('HTTP_HOST'):
url += environ['HTTP_HOST'] url += environ['HTTP_HOST']
@ -84,6 +84,8 @@ def origin(hosts):
hosts = [urlsplit(h) for h in hosts] hosts = [urlsplit(h) for h in hosts]
def func(environ): def func(environ):
if 'ISSO_CORS_ORIGIN' in environ:
return environ['ISSO_CORS_ORIGIN']
if not hosts: if not hosts:
return "http://invalid.local" return "http://invalid.local"

View File

@ -5,8 +5,8 @@ import sys
from setuptools import setup, find_packages from setuptools import setup, find_packages
requires = ['html5lib==0.9999999', 'itsdangerous', 'Jinja2', requires = ['itsdangerous', 'Jinja2', 'misaka>=2.0,<3.0', 'html5lib<0.9999999',
'misaka>=1.0,<2.0', 'werkzeug>=0.9'] 'werkzeug>=0.9']
if sys.version_info < (2, 7): if sys.version_info < (2, 7):
raise SystemExit("Python 2 versions < 2.7 are not supported.") raise SystemExit("Python 2 versions < 2.7 are not supported.")
@ -39,6 +39,7 @@ setup(
extras_require={ extras_require={
':python_version=="2.7"': ['ipaddr>=2.1', 'configparser'] ':python_version=="2.7"': ['ipaddr>=2.1', 'configparser']
}, },
setup_requires=["cffi>=1.3.0"],
entry_points={ entry_points={
'console_scripts': 'console_scripts':
['isso = isso:main'], ['isso = isso:main'],

View File

@ -46,6 +46,14 @@ notify = stdout
# Log console messages to file instead of standard output. # Log console messages to file instead of standard output.
log-file = log-file =
# adds property "gravatar_image" to json response when true
# will automatically build md5 hash by email and use "gravatar_url" to build
# the url to the gravatar image
gravatar = false
# default url for gravatar. {} is where the hash will be placed
gravatar-url = https://www.gravatar.com/avatar/{}?d=identicon
# Admin access password # Admin access password
admin_password = please_choose_a_strong_password admin_password = please_choose_a_strong_password
@ -180,3 +188,15 @@ salt = Eech7co8Ohloopo9Ol6baimi
# strengthening. Arguments have to be in that order, but can be reduced to # strengthening. Arguments have to be in that order, but can be reduced to
# pbkdf2:4096 for example to override the iterations only. # pbkdf2:4096 for example to override the iterations only.
algorithm = pbkdf2 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