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 rc.d script for FreeBSD
- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable
- Add preview button
- Add Atom feed at /feed?uri={thread-id}
- Add optionnal gravatar support
- Add nofollow noopener on links inside comments
- Add Dockerfile
- Upgraded to Misaka 2
- Some tests/travis/documentation improvements and fixes + pep8
- Improvement on german translation

@ -68,6 +68,7 @@ In chronological order:
* 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
@ -78,6 +79,9 @@ In chronological order:
* @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
@ -85,5 +89,11 @@ In chronological order:
* Facundo Batista <facundo@taniquetil.com.ar>
* Added a generic way to migrate from a json file
* @benjhess
* Optionnal gravatar support
* Steffen Prince @sprin
* Upgrade to Misaka 2
* [Your name or handle] <[email or website]>
* [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 python3 -m venv /isso \
&& . /isso/bin/activate \
&& python setup.py install \
&& pip install gunicorn
&& pip install gunicorn cffi \
&& python setup.py install
# Third, create final repository
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) \
$(shell ls isso/js/*.js | grep -vE "(min|dev)") \
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
${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)
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-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
data-isso-vote="true"
data-vote-levels=""
data-isso-vote-levels=""
data-isso-feed="false"
src="/prefix/js/embed.js"></script>
Furthermore you can override the automatic title detection inside
@ -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
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
--------------
@ -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
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 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
@ -308,6 +320,27 @@ algorithm
Arguments have to be in that order, but can be reduced to `pbkdf2:4096`
for example to override the iterations only.
.. _configure-rss:
RSS
---
Isso can provide an Atom feed for each comment thread. Users can use
them to subscribe to comments and be notified of changes. Atom feeds
are enabled as soon as there is a base URL defined in this section.
.. code-block:: ini
[rss]
base =
limit = 100
base
base URL to use to build complete URI to pages (by appending the URI from Isso)
limit
number of most recent comments to return for a thread
Appendum
--------
@ -320,3 +353,18 @@ Timedelta
You can add different types: `1m30s` equals to 90 seconds, `3h45m12s`
equals to 3 hours, 45 minutes and 12 seconds (12512 seconds).
Environment variables
---------------------
.. _environment-variables:
Isso also support configuration through some environment variables:
ISSO_CORS_ORIGIN
By default, `isso` will use the `Host` or else the `Referrer` HTTP header
of the request to defines a CORS `Access-Control-Allow-Origin` HTTP header
in the response.
This environent variable allows you to define a broader fixed value,
in order for example to share a single Isso instance among serveral of your
subdomains : `ISSO_CORS_ORIGIN=*.example.test`

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

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

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

@ -191,6 +191,24 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
return deferred.promise;
};
var feed = function(tid) {
return endpoint + "/feed?" + qs({uri: tid || location});
};
var preview = function(text) {
var deferred = Q.defer();
curl("POST", endpoint + "/preview", JSON.stringify({text: text}),
function(rv) {
if (rv.status === 200) {
deferred.resolve(JSON.parse(rv.body).text);
} else {
deferred.reject(rv.body);
}
});
return deferred.promise;
};
return {
endpoint: endpoint,
salt: salt,
@ -202,6 +220,8 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
fetch: fetch,
count: count,
like: like,
dislike: dislike
dislike: dislike,
feed: feed,
preview: preview
};
});

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -3,6 +3,8 @@ define({
"postbox-author": "名字 (可选)",
"postbox-email": "E-mail (可选)",
"postbox-website": "网站 (可选)",
"postbox-preview": "预览",
"postbox-edit": "编辑",
"postbox-submit": "提交",
"num-comments": "1 条评论\n{{ n }} 条评论",

@ -3,6 +3,8 @@ define({
"postbox-author": "名稱 (非必填)",
"postbox-email": "電子信箱 (非必填)",
"postbox-website": "個人網站 (非必填)",
"postbox-preview": "預覽",
"postbox-edit": "編輯",
"postbox-submit": "送出",
"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", {
"author": JSON.parse(localStorage.getItem("author")),
"email": JSON.parse(localStorage.getItem("email")),
"website": JSON.parse(localStorage.getItem("website"))
"website": JSON.parse(localStorage.getItem("website")),
"preview": ''
}));
// callback on success (e.g. to toggle the reply button)
@ -51,9 +52,27 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("[name='author']", el).placeholder.replace(/ \(.*\)/, "");
}
// preview function
$("[name='preview']", el).on("click", function() {
api.preview(utils.text($(".textarea", el).innerHTML)).then(
function(html) {
$(".preview .text", el).innerHTML = html;
el.classList.add('preview-mode');
});
});
// edit function
var edit = function() {
$(".preview .text", el).innerHTML = '';
el.classList.remove('preview-mode');
};
$("[name='edit']", el).on("click", edit);
$(".preview", el).on("click", edit);
// submit form, initialize optional fields with `null` and reset form.
// If replied to a comment, remove form completely.
$("[type=submit]", el).on("click", function() {
edit();
if (! el.validate()) {
return;
}
@ -228,7 +247,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
$("a.edit", footer).toggle("click",
function(toggler) {
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.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) {
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 (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) {
templates[name] = (function(jade) {
var fn;
if (js.compiled) {
return js(jade);
}
eval("fn = " + js);
return fn;
})(runtime);

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

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

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

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

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

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

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

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

@ -2,7 +2,6 @@
from __future__ import unicode_literals
import operator
import pkg_resources
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_SIMPLETREE = Version("0.95")
from isso.compat import reduce
import html5lib
from html5lib.sanitizer import HTMLSanitizer
from html5lib.serializer import HTMLSerializer
@ -23,7 +20,8 @@ def Sanitizer(elements, attributes):
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.
#
# [1] https://github.com/vmg/sundown/blob/master/html/html.c
@ -50,6 +48,11 @@ def sanitize(tokenizer, document):
if HTML5LIB_VERSION > HTML5LIB_SIMPLETREE:
builder = "etree"
for link in domtree.findall(".//{http://www.w3.org/1999/xhtml}a"):
if link.get('href', None):
link.set("rel", "nofollow noopener")
else:
builder = "simpletree"
@ -60,14 +63,14 @@ def sanitize(tokenizer, document):
return serializer.render(stream)
def Markdown(extensions=("strikethrough", "superscript", "autolink")):
def Markdown(extensions=("strikethrough", "superscript", "autolink",
"fenced-code")):
flags = reduce(operator.xor, map(
lambda ext: getattr(misaka, 'EXT_' + ext.upper()), extensions), 0)
md = misaka.Markdown(Unofficial(), extensions=flags)
renderer = Unofficial()
md = misaka.Markdown(renderer, extensions=extensions)
def inner(text):
rv = md.render(text).rstrip("\n")
rv = md(text).rstrip("\n")
if rv.startswith("<p>") or rv.endswith("</p>"):
return rv
return "<p>" + rv + "</p>"
@ -83,7 +86,7 @@ class Unofficial(misaka.HtmlRenderer):
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 ''
return "<pre><code{1}>{0}</code></pre>\n".format(text, lang)

@ -9,6 +9,7 @@ import functools
from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET
from werkzeug.http import dump_cookie
from werkzeug.wsgi import get_current_url
@ -20,10 +21,21 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str
from isso import utils, local
from isso.utils import (http, parse, JSONResponse as JSON,
from isso.utils import (http, parse,
JSONResponse as JSON, XMLResponse as XML,
render_template)
from isso.views import requires
from isso.utils.hash import sha1
from isso.utils.hash import md5
try:
from urlparse import urlparse
except ImportError:
from urllib.parse import urlparse
try:
from StringIO import StringIO
except ImportError:
from io import BytesIO as StringIO
# from Django appearently, looks good to me *duck*
__url_re = re.compile(
@ -81,7 +93,7 @@ def xhr(func):
class API(object):
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
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title'])
@ -91,6 +103,7 @@ class API(object):
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
@ -291,6 +304,8 @@ class API(object):
self.cache.set(
'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:
rv.pop(key)
@ -718,6 +733,18 @@ class API(object):
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):
for item in fetched_list:
@ -730,6 +757,8 @@ class API(object):
item['hash'] = val
item = self._add_gravatar_image(item)
for key in set(item.keys()) - API.FIELDS:
item.pop(key)
@ -824,7 +853,6 @@ class API(object):
@apiSuccessExample Counts of 5 threads:
[2, 18, 4, 0, 3]
"""
def counts(self, environ, request):
data = request.get_json()
@ -834,6 +862,125 @@ class API(object):
return JSON(self.comments.count(*data), 200)
"""
@api {get} /feed Atom feed for comments
@apiGroup Thread
@apiDescription
Provide an Atom feed for the given thread.
"""
@requires(str, 'uri')
def feed(self, environ, request, uri):
conf = self.isso.conf.section("rss")
if not conf.get('base'):
raise NotFound
args = {
'uri': uri,
'order_by': 'id',
'asc': 0,
'limit': conf.getint('limit')
}
try:
args['limit'] = max(int(request.args.get('limit')), args['limit'])
except TypeError:
pass
except ValueError:
return BadRequest("limit should be integer")
comments = self.comments.fetch(**args)
base = conf.get('base')
hostname = urlparse(base).netloc
# Let's build an Atom feed.
# RFC 4287: https://tools.ietf.org/html/rfc4287
# RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions)
# For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
feed = ET.Element('feed', {
'xmlns': 'http://www.w3.org/2005/Atom',
'xmlns:thr': 'http://purl.org/syndication/thread/1.0'
})
# For feed ID, we would use thread ID, but we may not have
# one. Therefore, we use the URI. We don't have a year
# either...
id = ET.SubElement(feed, 'id')
id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format(
hostname=hostname, uri=uri)
# For title, we don't have much either. Be pretty generic.
title = ET.SubElement(feed, 'title')
title.text = 'Comments for {hostname}{uri}'.format(
hostname=hostname, uri=uri)
comment0 = None
for comment in comments:
if comment0 is None:
comment0 = comment
entry = ET.SubElement(feed, 'entry')
# We don't use a real date in ID either to help with
# threading.
id = ET.SubElement(entry, 'id')
id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['id'])
title = ET.SubElement(entry, 'title')
title.text = 'Comment #{}'.format(comment['id'])
updated = ET.SubElement(entry, 'updated')
updated.text = '{}Z'.format(datetime.fromtimestamp(
comment['modified'] or comment['created']).isoformat())
author = ET.SubElement(entry, 'author')
name = ET.SubElement(author, 'name')
name.text = comment['author']
ET.SubElement(entry, 'link', {
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['id'])
})
content = ET.SubElement(entry, 'content', {
'type': 'html',
})
content.text = self.isso.render(comment['text'])
if comment['parent']:
ET.SubElement(entry, 'thr:in-reply-to', {
'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
hostname=hostname,
tid=comment['tid'],
id=comment['parent']),
'href': '{base}{uri}#isso-{id}'.format(
base=base,
uri=uri, id=comment['parent'])
})
# Updated is mandatory. If we have comments, we use the date
# of last modification of the first one (which is the last
# one). Otherwise, we use a fixed date.
updated = ET.Element('updated')
if comment0 is None:
updated.text = '1970-01-01T01:00:00Z'
else:
updated.text = datetime.fromtimestamp(
comment0['modified'] or comment0['created']).isoformat()
updated.text += 'Z'
feed.insert(0, updated)
output = StringIO()
ET.ElementTree(feed).write(output,
encoding='utf-8',
xml_declaration=True)
response = XML(output.getvalue(), 200)
# Add an etag/last-modified value for caching purpose
if comment0 is None:
response.set_etag('empty')
response.last_modified = 0
else:
response.set_etag('{tid}-{id}'.format(**comment0))
response.last_modified = comment0['modified'] or comment0['created']
return response.make_conditional(request)
def preview(self, environment, request):
data = request.get_json()
@ -843,14 +990,19 @@ class API(object):
return JSON({'text': self.isso.render(data["text"])}, 200)
def demo(self, env, req):
return redirect(get_current_url(env) + '/index.html')
return redirect(
get_current_url(env, strip_querystring=True) + '/index.html'
)
def login(self, env, req):
data = req.form
password = self.isso.conf.get("general", "admin_password")
if data['password'] and data['password'] == password:
response = redirect(get_current_url(
env, host_only=True) + '/admin')
response = redirect(re.sub(
r'/login$',
'/admin',
get_current_url(env, strip_querystring=True)
))
cookie = functools.partial(dump_cookie,
value=self.isso.sign({"logged": True}),
expires=datetime.now() + timedelta(1))

@ -30,7 +30,7 @@ def host(environ): # pragma: no cover
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'):
url += environ['HTTP_HOST']

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

@ -46,6 +46,14 @@ notify = stdout
# Log console messages to file instead of standard output.
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_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
# pbkdf2:4096 for example to override the iterations only.
algorithm = pbkdf2
[rss]
# Provide an Atom feed for each comment thread for users to subscribe to.
# The base URL of pages is needed to build the Atom feed. By appending
# the URI, we should get the complete URL to use to access the page
# with the comments. When empty, Atom feeds are disabled.
base =
# Limit the number of elements to return for each thread.
limit = 100

Loading…
Cancel
Save