Merge branch 'master' into generic-importer

pull/415/head
Benoît Latinier 6 years ago committed by GitHub
commit 2135743ea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -14,6 +14,12 @@ Changelog for Isso
- Add apidoc - Add apidoc
- Add rc.d script for FreeBSD - Add rc.d script for FreeBSD
- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable - 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 - Some tests/travis/documentation improvements and fixes + pep8
- Improvement on german translation - Improvement on german translation

@ -68,6 +68,7 @@ In chronological order:
* Lucas Cimon @Lucas-C * Lucas Cimon @Lucas-C
* Added the possibility to define CORS origins through ISSO_CORS_ORIGIN environment variable * Added the possibility to define CORS origins through ISSO_CORS_ORIGIN environment variable
* Fix a bug with <a> in <svg>
* Yuchen Pei @ycpei * Yuchen Pei @ycpei
* Fix link in moderation emails when isso is installed in a sub URL * Fix link in moderation emails when isso is installed in a sub URL
@ -78,6 +79,9 @@ In chronological order:
* @vincentbernat * @vincentbernat
* Added documentation about data-isso-id attribute (overriding the standard isso-thread-id) * Added documentation about data-isso-id attribute (overriding the standard isso-thread-id)
* Added multi-staged Dockerfile * 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 * @p-vitt & @M4a1x
* Documentation on troubleshooting for uberspace users * Documentation on troubleshooting for uberspace users
@ -85,5 +89,11 @@ In chronological order:
* Facundo Batista <facundo@taniquetil.com.ar> * Facundo Batista <facundo@taniquetil.com.ar>
* Added a generic way to migrate from a json file * Added a generic way to migrate from a json file
* @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]

@ -12,8 +12,8 @@ COPY --from=0 /src .
RUN apt-get -qqy update && apt-get -qqy install python3-dev sqlite3 RUN apt-get -qqy update && apt-get -qqy install python3-dev sqlite3
RUN python3 -m venv /isso \ RUN python3 -m venv /isso \
&& . /isso/bin/activate \ && . /isso/bin/activate \
&& python setup.py install \ && pip install gunicorn cffi \
&& pip install gunicorn && python setup.py install
# Third, create final repository # Third, create final repository
FROM python:3-slim-stretch FROM python:3-slim-stretch

@ -1,3 +1,5 @@
# INSTALLATION: pip install sphinx && npm install --global node-sass
ISSO_JS_SRC := $(shell find isso/js/app -type f) \ ISSO_JS_SRC := $(shell find isso/js/app -type f) \
$(shell ls isso/js/*.js | grep -vE "(min|dev)") \ $(shell ls isso/js/*.js | grep -vE "(min|dev)") \
isso/js/lib/requirejs-jade/jade.js isso/js/lib/requirejs-jade/jade.js
@ -53,7 +55,7 @@ man: $(DOCS_RST_SRC)
mv man/isso.conf.5 man/man5/isso.conf.5 mv man/isso.conf.5 man/man5/isso.conf.5
${DOCS_CSS_DST}: $(DOCS_CSS_SRC) $(DOCS_CSS_DEP) ${DOCS_CSS_DST}: $(DOCS_CSS_SRC) $(DOCS_CSS_DEP)
scss --no-cache $(DOCS_CSS_SRC) $@ node-sass --no-cache $(DOCS_CSS_SRC) $@
${DOCS_HTML_DST}: $(DOCS_RST_SRC) $(DOCS_CSS_DST) ${DOCS_HTML_DST}: $(DOCS_RST_SRC) $(DOCS_CSS_DST)
sphinx-build -b dirhtml docs/ $@ sphinx-build -b dirhtml docs/ $@

@ -20,7 +20,8 @@ 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
@ -107,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
-------------- --------------
@ -125,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.

@ -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
@ -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
-------- --------
@ -320,3 +353,18 @@ Timedelta
You can add different types: `1m30s` equals to 90 seconds, `3h45m12s` You can add different types: `1m30s` equals to 90 seconds, `3h45m12s`
equals to 3 hours, 45 minutes and 12 seconds (12512 seconds). equals to 3 hours, 45 minutes and 12 seconds (12512 seconds).
Environment variables
---------------------
.. _environment-variables:
Isso also support configuration through some environment variables:
ISSO_CORS_ORIGIN
By default, `isso` will use the `Host` or else the `Referrer` HTTP header
of the request to defines a CORS `Access-Control-Allow-Origin` HTTP header
in the response.
This environent variable allows you to define a broader fixed value,
in order for example to share a single Isso instance among serveral of your
subdomains : `ISSO_CORS_ORIGIN=*.example.test`

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

@ -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,28 @@
.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
);
}
@media screen and (max-width:600px) { @media screen and (max-width:600px) {
.isso-postbox > .form-wrapper > .auth-section .input-wrapper { .isso-postbox > .form-wrapper > .auth-section .input-wrapper {
display: block; display: block;
@ -218,9 +249,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;
}
} }

@ -159,7 +159,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`.
""" """
@ -181,7 +182,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 ?')

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

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

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

@ -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": "Все още няма коментари",

@ -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ářů",

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

@ -1,8 +1,10 @@
define({ define({
"postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)", "postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)",
"postbox-author": "Name (optional)", "postbox-author": "Name (optional)",
"postbox-email": "Email (optional)", "postbox-email": "E-Mail (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",

@ -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": "Δεν υπάρχουν σχόλια",

@ -3,10 +3,13 @@ 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",
"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",

@ -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ŭ",

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

@ -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 }} نظر",

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

@ -3,9 +3,12 @@ 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",
"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",

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

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

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

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

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

@ -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": "Пока нет комментариев",

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

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

@ -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": "1 条评论\n{{ n }} 条评论", "num-comments": "1 条评论\n{{ n }} 条评论",

@ -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 }} 則留言",

@ -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)
@ -51,9 +52,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;
} }
@ -228,7 +247,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() {
@ -256,7 +275,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) {

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

@ -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}')

@ -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,3 +19,9 @@ 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'))

@ -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.appendChild(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>');

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

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

@ -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>'])

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

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

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

@ -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']) 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image'])
# comment fields, that can be submitted # comment fields, that can be submitted
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title']) ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title'])
@ -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>')),
@ -291,6 +304,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)
@ -718,6 +733,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:
@ -730,6 +757,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)
@ -824,7 +853,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()
@ -834,6 +862,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()
@ -843,14 +990,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))

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

@ -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'],

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

Loading…
Cancel
Save