diff --git a/CHANGES.rst b/CHANGES.rst index 2b290a6..bb055b7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog for Isso 0.10.7 (unreleased) ------------------- -- Fix Chinese translation +- Fix Chinese translation & typo in CJK +- Fix link in moderation mails if isso is setup on a sub-url (e.g. domain.tld/comments/) - Add Danish translation - Add Hungarian translation - Add Persian translation @@ -12,7 +13,15 @@ Changelog for Isso - Add links highlighting in comments - Add apidoc - Add rc.d script for FreeBSD -- Some tests/travis/documentation improvements and fixes +- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable +- Add preview button +- Add Atom feed at /feed?uri={thread-id} +- Add optionnal gravatar support +- Add nofollow noopener on links inside comments +- Add Dockerfile +- Upgraded to Misaka 2 +- Some tests/travis/documentation improvements and fixes + pep8 +- Improvement on german translation 0.10.6 (2016-09-22) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 0b5e649..4581ef3 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -51,12 +51,46 @@ In chronological order: * Added configuration to require email addresses (no validation) * Fix Vagrantfile -* Benoît Latinier +* Benoît Latinier @blatinier * Fix thread discovery * Added mandatory author + * Added admin interface * Ivan Pantic * Added vote levels +* Martin Schenck @schemar + * Improvement in the german translation + +* @cclauss + * Pep8 and drop of legacy supports (old python & debian version tested in travis) + * Make travis use pyflakes + +* Lucas Cimon @Lucas-C + * Added the possibility to define CORS origins through ISSO_CORS_ORIGIN environment variable + * Fix a bug with in + +* Yuchen Pei @ycpei + * Fix link in moderation emails when isso is installed in a sub URL + +* @Rocket1184 + * Fix typo in CJK translations + +* @vincentbernat + * Added documentation about data-isso-id attribute (overriding the standard isso-thread-id) + * Added multi-staged Dockerfile + * Added atom feed + * Added a nofollow/noopener on links inside comments to protect against bots + * Added a preview using the existing preview endpoint + +* @p-vitt & @M4a1x + * Documentation on troubleshooting for uberspace users + +* @benjhess + * Optionnal gravatar support + +* Steffen Prince @sprin + * Upgrade to Misaka 2 + * [Your name or handle] <[email or website]> * [Brief summary of your changes] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a8203c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# First, compile JS stuff +FROM node +WORKDIR /src/ +COPY . . +RUN npm install -g requirejs uglify-js jade bower +RUN make init js + +# Second, create virtualenv +FROM python:3-stretch +WORKDIR /src/ +COPY --from=0 /src . +RUN apt-get -qqy update && apt-get -qqy install python3-dev sqlite3 +RUN python3 -m venv /isso \ + && . /isso/bin/activate \ + && python setup.py install \ + && pip install gunicorn + +# Third, create final repository +FROM python:3-slim-stretch +WORKDIR /isso/ +COPY --from=1 /isso . + +# Configuration +VOLUME /db /config +EXPOSE 8080 +ENV ISSO_SETTINGS /config/isso.cfg +CMD ["/isso/bin/gunicorn", "-b", "0.0.0.0:8080", "-w", "4", "--preload", "isso.run"] + +# Example of use: +# +# docker build -t isso . +# docker run -it --rm -v /opt/isso:/config -v /opt/isso:/db -v $PWD:$PWD isso /isso/bin/isso -c \$ISSO_SETTINGS import disqus.xml +# docker run -d --rm --name isso -p 8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso diff --git a/docs/contribute.rst b/docs/contribute.rst index 8a3886d..c349a47 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -59,5 +59,3 @@ definitely need help: - delete or activate comments matching a filter (e.g. name, email, ip address) - close threads and remove threads completely - - - edit comments diff --git a/docs/docs/configuration/client.rst b/docs/docs/configuration/client.rst index 14f0c86..7b074b4 100644 --- a/docs/docs/configuration/client.rst +++ b/docs/docs/configuration/client.rst @@ -7,6 +7,7 @@ preferably in the script tag which embeds the JS: .. code-block:: html Furthermore you can override the automatic title detection inside -the embed tag, e.g.: +the embed tag, as well as the thread ID, e.g.: .. code-block:: html -
+
data-isso --------- @@ -106,6 +108,14 @@ scheme is based in `this color palette `_. 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 -------------- @@ -124,3 +134,10 @@ For example, the value `"-5,5"` will cause each `isso-comment` to be given one o - `isso-vote-level-2` for scores of `5` and greater 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. diff --git a/docs/docs/configuration/server.rst b/docs/docs/configuration/server.rst index cb39ccd..b856003 100644 --- a/docs/docs/configuration/server.rst +++ b/docs/docs/configuration/server.rst @@ -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 @@ -108,7 +120,7 @@ Enable moderation queue and handling of comments still in moderation queue enabled enable comment moderation queue. This option only affects new comments. - Comments in modertion queue are not visible to other users until you + Comments in moderation queue are not visible to other users until you activate them. purge-after @@ -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 -------- diff --git a/docs/docs/extras/api.rst b/docs/docs/extras/api.rst index 348a256..bcb374b 100644 --- a/docs/docs/extras/api.rst +++ b/docs/docs/extras/api.rst @@ -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. diff --git a/docs/docs/install.rst b/docs/docs/install.rst index 8ce4f8c..eb2ef35 100644 --- a/docs/docs/install.rst +++ b/docs/docs/install.rst @@ -39,10 +39,10 @@ package manager. .. code-block:: sh # for Debian/Ubuntu - ~> sudo apt-get install python-setuptools python-virtualenv + ~> sudo apt-get install python-setuptools python-virtualenv python-dev # Fedora/Red Hat - ~> sudo yum install python-setuptools python-virtualenv + ~> sudo yum install python-setuptools python-virtualenv python-devel The next steps should be done as regular user, not as root (although possible but not recommended): @@ -149,7 +149,18 @@ Prebuilt Packages * Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr repository. Built from Pypi, includes a systemctl unit script. -* Docker Image: https://registry.hub.docker.com/u/bl4n/isso/ +Build a Docker image +-------------------- + +You can get a Docker image by running ``docker build . -t +isso``. Assuming you have your configuration in ``/opt/isso``, you can +use the following command to spawn the Docker container: + +.. code-block:: sh + + ~> docker run -d --rm --name isso -p 127.0.0.1:8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso + +Then, you can use a reverse proxy to expose port 8080. Install from Source ------------------- diff --git a/docs/docs/troubleshooting.rst b/docs/docs/troubleshooting.rst index 20ee8dd..a395a0a 100644 --- a/docs/docs/troubleshooting.rst +++ b/docs/docs/troubleshooting.rst @@ -1,6 +1,12 @@ Troubleshooting =============== +For uberspace users +------------------- +Some uberspace users experienced problems with isso and they solved their +issue by adding `DirectoryIndex disabled` as the first line in the `.htaccess` +file for the domain the isso server is running on. + pkg_ressources.DistributionNotFound ----------------------------------- diff --git a/isso/css/isso.css b/isso/css/isso.css index b7fe4b1..63737b2 100644 --- a/isso/css/isso.css +++ b/isso/css/isso.css @@ -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,27 @@ .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 + ); .isso-postbox > .form-wrapper > .notification-section { display: none; padding-bottom: 10px; @@ -222,9 +252,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; - } } diff --git a/isso/db/comments.py b/isso/db/comments.py index c23ad3c..0ba3964 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -173,7 +173,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`. """ @@ -195,7 +196,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 ?') diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 6865551..ccea39b 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -70,6 +70,12 @@ class SMTP(object): self.isso = isso self.conf = isso.conf.section("smtp") + gh = isso.conf.get("general", "host") + if type(gh) == str: + self.general_host = gh + #if gh is not a string then gh is a list + else: + self.general_host = gh[0] # test SMTP connectivity try: @@ -118,7 +124,7 @@ class SMTP(object): rv.write("---\n") if admin: - uri = local("host") + "/id/%i" % comment["id"] + uri = self.general_host + "/id/%i" % comment["id"] key = self.isso.sign(comment["id"]) rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) @@ -127,7 +133,7 @@ class SMTP(object): rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) else: - uri = local("host") + "/id/%i" % comment_parent["id"] + uri = self.general_host + "/id/%i" % comment_parent["id"] key = self.isso.sign(('unsubscribe', comment_parent["id"])) rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + key)) diff --git a/isso/js/app/api.js b/isso/js/app/api.js index d0fbf2f..1496892 100644 --- a/isso/js/app/api.js +++ b/isso/js/app/api.js @@ -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 }; }); diff --git a/isso/js/app/config.js b/isso/js/app/config.js index 952d588..25af27c 100644 --- a/isso/js/app/config.js +++ b/isso/js/app/config.js @@ -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"); diff --git a/isso/js/app/count.js b/isso/js/app/count.js index a1fff4b..58eb230 100644 --- a/isso/js/app/count.js +++ b/isso/js/app/count.js @@ -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; } diff --git a/isso/js/app/i18n/bg.js b/isso/js/app/i18n/bg.js index 45ac24b..a13ff09 100644 --- a/isso/js/app/i18n/bg.js +++ b/isso/js/app/i18n/bg.js @@ -3,6 +3,8 @@ define({ "postbox-author": "Име/псевдоним (незадължително)", "postbox-email": "Ел. поща (незадължително)", "postbox-website": "Уебсайт (незадължително)", + "postbox-preview": "преглед", + "postbox-edit": "Редактиране", "postbox-submit": "Публикуване", "num-comments": "1 коментар\n{{ n }} коментара", "no-comments": "Все още няма коментари", diff --git a/isso/js/app/i18n/cs.js b/isso/js/app/i18n/cs.js index 77e6401..11dfcbb 100644 --- a/isso/js/app/i18n/cs.js +++ b/isso/js/app/i18n/cs.js @@ -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ářů", diff --git a/isso/js/app/i18n/da.js b/isso/js/app/i18n/da.js index eb64fc4..d97cd4c 100644 --- a/isso/js/app/i18n/da.js +++ b/isso/js/app/i18n/da.js @@ -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", diff --git a/isso/js/app/i18n/de.js b/isso/js/app/i18n/de.js index f7def26..04e1739 100644 --- a/isso/js/app/i18n/de.js +++ b/isso/js/app/i18n/de.js @@ -3,6 +3,8 @@ define({ "postbox-author": "Name (optional)", "postbox-email": "Email (optional)", "postbox-website": "Website (optional)", + "postbox-preview": "Vorschau", + "postbox-edit": "Bearbeiten", "postbox-submit": "Abschicken", "num-comments": "1 Kommentar\n{{ n }} Kommentare", "no-comments": "Bisher keine Kommentare", diff --git a/isso/js/app/i18n/el_GR.js b/isso/js/app/i18n/el_GR.js index 5155a2d..db181cc 100644 --- a/isso/js/app/i18n/el_GR.js +++ b/isso/js/app/i18n/el_GR.js @@ -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": "Δεν υπάρχουν σχόλια", diff --git a/isso/js/app/i18n/en.js b/isso/js/app/i18n/en.js index 7ba8b49..b72e813 100644 --- a/isso/js/app/i18n/en.js +++ b/isso/js/app/i18n/en.js @@ -3,11 +3,14 @@ define({ "postbox-author": "Name (optional)", "postbox-email": "E-mail (optional)", "postbox-website": "Website (optional)", + "postbox-preview": "Preview", + "postbox-edit": "Edit", "postbox-submit": "Submit", "postbox-notification": "Subscribe to email notification of replies", "num-comments": "One Comment\n{{ n }} Comments", "no-comments": "No Comments Yet", + "atom-feed": "Atom feed", "comment-reply": "Reply", "comment-edit": "Edit", diff --git a/isso/js/app/i18n/eo.js b/isso/js/app/i18n/eo.js index 76150f3..e9ee6c6 100644 --- a/isso/js/app/i18n/eo.js +++ b/isso/js/app/i18n/eo.js @@ -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ŭ", diff --git a/isso/js/app/i18n/es.js b/isso/js/app/i18n/es.js index c25d6cd..565ef14 100644 --- a/isso/js/app/i18n/es.js +++ b/isso/js/app/i18n/es.js @@ -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", diff --git a/isso/js/app/i18n/fa.js b/isso/js/app/i18n/fa.js index c323778..9b6da58 100644 --- a/isso/js/app/i18n/fa.js +++ b/isso/js/app/i18n/fa.js @@ -3,6 +3,8 @@ define({ "postbox-author": "اسم (اختیاری)", "postbox-email": "ایمیل (اختیاری)", "postbox-website": "سایت (اختیاری)", + "postbox-preview": "پیشنمایش", + "postbox-edit": "ویرایش", "postbox-submit": "ارسال", "num-comments": "یک نظر\n{{ n }} نظر", diff --git a/isso/js/app/i18n/fi.js b/isso/js/app/i18n/fi.js index 80b6316..4def698 100644 --- a/isso/js/app/i18n/fi.js +++ b/isso/js/app/i18n/fi.js @@ -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", diff --git a/isso/js/app/i18n/fr.js b/isso/js/app/i18n/fr.js index 2f2573b..449ac1e 100644 --- a/isso/js/app/i18n/fr.js +++ b/isso/js/app/i18n/fr.js @@ -3,10 +3,13 @@ define({ "postbox-author": "Nom (optionnel)", "postbox-email": "Courriel (optionnel)", "postbox-website": "Site web (optionnel)", + "postbox-preview": "Aperçu", + "postbox-edit": "Éditer", "postbox-submit": "Soumettre", "postbox-notification": "S'abonner aux notifications de réponses", "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", diff --git a/isso/js/app/i18n/hr.js b/isso/js/app/i18n/hr.js index 1ae6452..84c31f9 100644 --- a/isso/js/app/i18n/hr.js +++ b/isso/js/app/i18n/hr.js @@ -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", diff --git a/isso/js/app/i18n/hu.js b/isso/js/app/i18n/hu.js index e0bf7d6..e06c513 100644 --- a/isso/js/app/i18n/hu.js +++ b/isso/js/app/i18n/hu.js @@ -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", diff --git a/isso/js/app/i18n/it.js b/isso/js/app/i18n/it.js index 31eeb2c..f193f95 100644 --- a/isso/js/app/i18n/it.js +++ b/isso/js/app/i18n/it.js @@ -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", diff --git a/isso/js/app/i18n/nl.js b/isso/js/app/i18n/nl.js index 04164b6..107a882 100644 --- a/isso/js/app/i18n/nl.js +++ b/isso/js/app/i18n/nl.js @@ -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", diff --git a/isso/js/app/i18n/pl.js b/isso/js/app/i18n/pl.js index d9afe7d..bba7bac 100644 --- a/isso/js/app/i18n/pl.js +++ b/isso/js/app/i18n/pl.js @@ -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", diff --git a/isso/js/app/i18n/ru.js b/isso/js/app/i18n/ru.js index a5af03e..662e825 100644 --- a/isso/js/app/i18n/ru.js +++ b/isso/js/app/i18n/ru.js @@ -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": "Пока нет комментариев", diff --git a/isso/js/app/i18n/sv.js b/isso/js/app/i18n/sv.js index cafbdda..a1b50a3 100644 --- a/isso/js/app/i18n/sv.js +++ b/isso/js/app/i18n/sv.js @@ -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", diff --git a/isso/js/app/i18n/vi.js b/isso/js/app/i18n/vi.js index 72a3092..6b54d23 100644 --- a/isso/js/app/i18n/vi.js +++ b/isso/js/app/i18n/vi.js @@ -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", diff --git a/isso/js/app/i18n/zh_CN.js b/isso/js/app/i18n/zh_CN.js index b9d4582..1bd1801 100644 --- a/isso/js/app/i18n/zh_CN.js +++ b/isso/js/app/i18n/zh_CN.js @@ -1,11 +1,13 @@ define({ - "postbox-text": "在此输入评论 (最少3个字符)", + "postbox-text": "在此输入评论 (最少 3 个字符)", "postbox-author": "名字 (可选)", "postbox-email": "E-mail (可选)", "postbox-website": "网站 (可选)", + "postbox-preview": "预习", + "postbox-edit": "编辑", "postbox-submit": "提交", - "num-comments": "1条评论\n{{ n }}条评论", + "num-comments": "1 条评论\n{{ n }} 条评论", "no-comments": "还没有评论", "comment-reply": "回复", @@ -21,10 +23,10 @@ define({ "comment-hidden": "{{ n }} 条评论已隐藏", "date-now": "刚刚", - "date-minute": "1分钟前\n{{ n }}分钟前", - "date-hour": "1小时前\n{{ n }}小时前", - "date-day": "昨天\n{{ n }}天前", - "date-week": "上周\n{{ n }}周前", - "date-month": "上个月\n{{ n }}个月前", - "date-year": "去年\n{{ n }}年前" + "date-minute": "1 分钟前\n{{ n }} 分钟前", + "date-hour": "1 小时前\n{{ n }} 小时前", + "date-day": "昨天\n{{ n }} 天前", + "date-week": "上周\n{{ n }} 周前", + "date-month": "上个月\n{{ n }} 个月前", + "date-year": "去年\n{{ n }} 年前" }); diff --git a/isso/js/app/i18n/zh_TW.js b/isso/js/app/i18n/zh_TW.js index 9bb59fa..7bdf7e5 100644 --- a/isso/js/app/i18n/zh_TW.js +++ b/isso/js/app/i18n/zh_TW.js @@ -1,11 +1,13 @@ define({ - "postbox-text": "在此輸入留言(至少3個字元)", + "postbox-text": "在此輸入留言(至少 3 個字元)", "postbox-author": "名稱 (非必填)", "postbox-email": "電子信箱 (非必填)", "postbox-website": "個人網站 (非必填)", + "postbox-preview": "預習", + "postbox-edit": "編輯", "postbox-submit": "送出", - "num-comments": "1則留言\n{{ n }}則留言", + "num-comments": "1 則留言\n{{ n }} 則留言", "no-comments": "尚無留言", "comment-reply": "回覆", @@ -18,13 +20,13 @@ define({ "comment-deleted": "留言已刪", "comment-queued": "留言待審", "comment-anonymous": "匿名", - "comment-hidden": "{{ n }}則隱藏留言", + "comment-hidden": "{{ n }} 則隱藏留言", "date-now": "剛剛", - "date-minute": "1分鐘前\n{{ n }}分鐘前", - "date-hour": "1小時前\n{{ n }}小時前", - "date-day": "昨天\n{{ n }}天前", - "date-week": "上週\n{{ n }}週前", - "date-month": "上個月\n{{ n }}個月前", - "date-year": "去年\n{{ n }}年前" + "date-minute": "1 分鐘前\n{{ n }} 分鐘前", + "date-hour": "1 小時前\n{{ n }} 小時前", + "date-day": "昨天\n{{ n }} 天前", + "date-week": "上週\n{{ n }} 週前", + "date-month": "上個月\n{{ n }} 個月前", + "date-year": "去年\n{{ n }} 年前" }); diff --git a/isso/js/app/isso.js b/isso/js/app/isso.js index 665db73..e243349 100644 --- a/isso/js/app/isso.js +++ b/isso/js/app/isso.js @@ -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) @@ -62,9 +63,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; } @@ -240,7 +259,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() { @@ -268,7 +287,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) { diff --git a/isso/js/app/jade.js b/isso/js/app/jade.js index 46d6269..0064d60 100644 --- a/isso/js/app/jade.js +++ b/isso/js/app/jade.js @@ -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); diff --git a/isso/js/app/text/comment.jade b/isso/js/app/text/comment.jade index 4884bf7..1d97499 100644 --- a/isso/js/app/text/comment.jade +++ b/isso/js/app/text/comment.jade @@ -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}') diff --git a/isso/js/app/text/postbox.jade b/isso/js/app/text/postbox.jade index bc7ef02..5209daa 100644 --- a/isso/js/app/text/postbox.jade +++ b/isso/js/app/text/postbox.jade @@ -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,6 +19,12 @@ 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')) section(class='notification-section') label input(type='checkbox' name='notification') diff --git a/isso/js/embed.js b/isso/js/embed.js index 680880b..a0a53da 100644 --- a/isso/js/embed.js +++ b/isso/js/embed.js @@ -27,6 +27,13 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/ return console.log("abort, #isso-thread is missing"); } + if (config["feed"]) { + var feedLink = $.new('a', i18n.translate('atom-feed')); + var feedLinkWrapper = $.new('span.isso-feedlink'); + feedLink.href = api.feed($("#isso-thread").getAttribute("data-isso-id")); + feedLinkWrapper.append(feedLink); + $("#isso-thread").append(feedLinkWrapper); + } $("#isso-thread").append($.new('h4')); $("#isso-thread").append(new isso.Postbox(null)); $("#isso-thread").append('
'); diff --git a/isso/js/lib/requirejs-jade/jade.js b/isso/js/lib/requirejs-jade/jade.js index 59189a4..383d3f5 100644 --- a/isso/js/lib/requirejs-jade/jade.js +++ b/isso/js/lib/requirejs-jade/jade.js @@ -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"); } } diff --git a/isso/migrate.py b/isso/migrate.py index f6297b7..2296e3c 100644 --- a/isso/migrate.py +++ b/isso/migrate.py @@ -55,7 +55,7 @@ class Progress(object): if time() - self.last > 0.2: sys.stdout.write("\r{0}".format(" " * cols)) - sys.stdout.write("\r[{0:.0%}] {1}".format(i/self.end, message)) + sys.stdout.write("\r[{0:.0%}] {1}".format(i / self.end, message)) sys.stdout.flush() self.last = time() diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index cc27ae8..fa32039 100644 --- a/isso/tests/test_comments.py +++ b/isso/tests/test_comments.py @@ -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, """ +1970-01-01T01:00:00Ztag:example.org,2018:/isso/thread/path/nothingComments for example.org/path/nothing""") + + 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, """ +2018-04-01T10:00:00Ztag:example.org,2018:/isso/thread/path/Comments for example.org/path/tag:example.org,2018:/isso/1/2Comment #22018-04-01T10:00:00Z<p><em>Second</em></p>tag:example.org,2018:/isso/1/1Comment #12018-04-01T10:00:00Z<p>First</p>""") + def testCounts(self): self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404) diff --git a/isso/tests/test_html.py b/isso/tests/test_html.py index 316fbf8..2b3b15c 100644 --- a/isso/tests/test_html.py +++ b/isso/tests/test_html.py @@ -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: ', 'Look: '), ('
Ha', - 'Ha'), + ['Ha', + 'Ha']), ('Ha', 'Ha'), ('

Test

', '

Test

'), ('', '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"), - '

http://example.org/ and sms:+1234567890

') + self.assertIn(renderer("http://example.org/ and sms:+1234567890"), + ['

http://example.org/ and sms:+1234567890

', + '

http://example.org/ and sms:+1234567890

']) diff --git a/isso/utils/__init__.py b/isso/utils/__init__.py index a0a07bc..ed925a9 100644 --- a/isso/utils/__init__.py +++ b/isso/utils/__init__.py @@ -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) diff --git a/isso/utils/hash.py b/isso/utils/hash.py index 0638c1b..93a084e 100644 --- a/isso/utils/hash.py +++ b/isso/utils/hash.py @@ -110,3 +110,4 @@ def new(conf): sha1 = Hash(func="sha1").uhash +md5 = Hash(func="md5").uhash diff --git a/isso/utils/html.py b/isso/utils/html.py index fca3c7e..4acfcc0 100644 --- a/isso/utils/html.py +++ b/isso/utils/html.py @@ -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 tag, + # attributes found in Sundown's HTML serializer [1] + # except for 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("

") or rv.endswith("

"): return rv return "

" + rv + "

" @@ -83,7 +86,7 @@ class Unofficial(misaka.HtmlRenderer): to , compatible with Highlight.js. """ - def block_code(self, text, lang): + def blockcode(self, text, lang): lang = ' class="{0}"'.format(lang) if lang else '' return "
{0}
\n".format(text, lang) diff --git a/isso/views/comments.py b/isso/views/comments.py index 9096925..4c12820 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -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', 'notification']) + 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification']) # comment fields, that can be submitted ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification']) @@ -91,6 +103,7 @@ class API(object): ('new', ('POST', '/new')), ('count', ('GET', '/count')), ('counts', ('POST', '/count')), + ('feed', ('GET', '/feed')), ('view', ('GET', '/id/')), ('edit', ('PUT', '/id/')), ('delete', ('DELETE', '/id/')), @@ -292,6 +305,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) @@ -779,6 +794,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: @@ -791,6 +818,8 @@ class API(object): item['hash'] = val + item = self._add_gravatar_image(item) + for key in set(item.keys()) - API.FIELDS: item.pop(key) @@ -885,7 +914,6 @@ class API(object): @apiSuccessExample Counts of 5 threads: [2, 18, 4, 0, 3] """ - def counts(self, environ, request): data = request.get_json() @@ -895,6 +923,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() @@ -904,14 +1051,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)) diff --git a/isso/wsgi.py b/isso/wsgi.py index 1788d47..864d017 100644 --- a/isso/wsgi.py +++ b/isso/wsgi.py @@ -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'] @@ -84,6 +84,8 @@ def origin(hosts): hosts = [urlsplit(h) for h in hosts] def func(environ): + if 'ISSO_CORS_ORIGIN' in environ: + return environ['ISSO_CORS_ORIGIN'] if not hosts: return "http://invalid.local" diff --git a/setup.py b/setup.py index ef866fe..74b1c54 100644 --- a/setup.py +++ b/setup.py @@ -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'], diff --git a/share/isso.conf b/share/isso.conf index 883e2b1..57a1155 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -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