diff --git a/.travis.yml b/.travis.yml index c27f79f..4cb97a4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,22 @@ language: python -python: 2.7 -env: - - TOX_ENV=py26 - - TOX_ENV=py27 - - TOX_ENV=py33 - - TOX_ENV=py34 - - TOX_ENV=squeeze - - TOX_ENV=wheezy matrix: - allow_failures: - - env: TOX_ENV=squeeze + include: + - python: 2.7 + env: TOX_ENV=py27 + - python: 3.4 + env: TOX_ENV=py34 + - python: 3.5 + env: TOX_ENV=py35 + - python: 3.6 + env: TOX_ENV=py36 install: - pip install -U pip - - pip install tox + - pip install flake8 tox - sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm script: - tox -e $TOX_ENV + - IGNORE=E226,E241,E265,E402,E501,E704 + - flake8 . --count --ignore=${IGNORE} --max-line-length=127 --show-source --statistics notifications: irc: channels: diff --git a/CHANGES.rst b/CHANGES.rst index 0d1c7da..5365c2e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,18 @@ Changelog for Isso 0.10.7 (unreleased) ------------------- -- Nothing changed yet. +- Fix Chinese translation & typo in CJK +- Fix link in moderation mails if isso is setup on a sub-url (e.g. domain.tld/comments/) +- Add Danish translation +- Add Hungarian translation +- Add Persian translation +- Add admin interface +- Add links highlighting in comments +- Add apidoc +- Add rc.d script for FreeBSD +- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable +- Some tests/travis/documentation improvements and fixes + pep8 +- Improvement on german translation 0.10.6 (2016-09-22) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 0b5e649..576f9cb 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -51,12 +51,40 @@ 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 + * [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/MANIFEST.in b/MANIFEST.in index c4edbe1..4c63d8e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,3 +9,9 @@ include isso/js/count.min.js include isso/js/count.dev.js include isso/defaults.ini + +include isso/templates/admin.html +include isso/templates/login.html +include isso/css/admin.css +include isso/css/isso.css +include isso/img/isso.svg diff --git a/Makefile b/Makefile index ff2a55e..2a44cb3 100644 --- a/Makefile +++ b/Makefile @@ -34,9 +34,9 @@ init: check: @echo "Python 2.x" - -@python2 -m pyflakes $(ISSO_PY_SRC) + @python2 -m pyflakes $(filter-out isso/compat.py,$(ISSO_PY_SRC)) @echo "Python 3.x" - -@python3 -m pyflakes $(ISSO_PY_SRC) + @python3 -m pyflakes $(filter-out isso/compat.py,$(ISSO_PY_SRC)) isso/js/%.min.js: $(ISSO_JS_SRC) $(ISSO_CSS) $(RJS) -o isso/js/build.$*.js out=$@ diff --git a/apidoc.json b/apidoc.json new file mode 100644 index 0000000..a960a32 --- /dev/null +++ b/apidoc.json @@ -0,0 +1,11 @@ +{ + "name": "isso", + "description": "a Disqus alternative", + "title": "isso API", + "order": ["Thread", "Comment"], + "template": { + "withCompare": false + } + +} + diff --git a/docs/_isso/layout.html b/docs/_isso/layout.html index 2462c30..c0f9476 100644 --- a/docs/_isso/layout.html +++ b/docs/_isso/layout.html @@ -10,7 +10,8 @@ VERSION: '{{ release|e }}', COLLAPSE_INDEX: false, FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}', - HAS_SOURCE: {{ has_source|lower }} + HAS_SOURCE: {{ has_source|lower }}, + SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}' }; {%- for scriptfile in script_files %} diff --git a/docs/_isso/remove_heading.py b/docs/_isso/remove_heading.py index 48d7708..f15f5d5 100644 --- a/docs/_isso/remove_heading.py +++ b/docs/_isso/remove_heading.py @@ -9,4 +9,3 @@ class IssoTranslator(HTMLTranslator): if self.section_level == 1: raise nodes.SkipNode HTMLTranslator.visit_title(self, node) - diff --git a/docs/conf.py b/docs/conf.py index 1db8715..0d0c005 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -171,7 +171,7 @@ html_static_path = ['_static'] # Additional templates that should be rendered to pages, maps page names to # template names. -html_additional_pages = {"index": "docs/index.html"} +html_additional_pages = {"index": "index.html"} # If false, no module index is generated. html_domain_indices = False @@ -200,22 +200,22 @@ htmlhelp_basename = 'Issodoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'Isso.tex', u'Isso Documentation', - u'Martin Zimmermann', 'manual'), + ('index', 'Isso.tex', u'Isso Documentation', + u'Martin Zimmermann', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -260,9 +260,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'Isso', u'Isso Documentation', - u'Martin Zimmermann', 'Isso', 'a commenting server similar to Disqus', - 'Miscellaneous'), + ('index', 'Isso', u'Isso Documentation', + u'Martin Zimmermann', 'Isso', 'a commenting server similar to Disqus', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. 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 37d32ea..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 --------- @@ -133,13 +135,9 @@ For example, the value `"-5,5"` will cause each `isso-comment` to be given one o These classes can then be used to customize the appearance of comments (eg. put a star on popular comments) -data-isso-id ------------- +data-isso-feed +-------------- -Set a custom thread id, defaults to current URI. This attribute needs -to be used with the data-title attribute in order to work. -If you use a comment counter, add this attribute to the link tag, too. - -.. code-block:: html - -
+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 9cd11e7..b856003 100644 --- a/docs/docs/configuration/server.rst +++ b/docs/docs/configuration/server.rst @@ -120,7 +120,7 @@ Enable moderation queue and handling of comments still in moderation queue enabled enable comment moderation queue. This option only affects new comments. - Comments in modertion queue are not visible to other users until you + Comments in moderation queue are not visible to other users until you activate them. purge-after @@ -320,6 +320,27 @@ algorithm Arguments have to be in that order, but can be reduced to `pbkdf2:4096` for example to override the iterations only. +.. _configure-rss: + +RSS +--- + +Isso can provide an Atom feed for each comment thread. Users can use +them to subscribe to comments and be notified of changes. Atom feeds +are enabled as soon as there is a base URL defined in this section. + +.. code-block:: ini + + [rss] + base = + limit = 100 + +base + base URL to use to build complete URI to pages (by appending the URI from Isso) + +limit + number of most recent comments to return for a thread + Appendum -------- diff --git a/docs/docs/extras/api.rst b/docs/docs/extras/api.rst index 4c9f1d7..bcb374b 100644 --- a/docs/docs/extras/api.rst +++ b/docs/docs/extras/api.rst @@ -23,11 +23,11 @@ Isso: "mode": 1, "hash": "4505c1eeda98", "author": null, - "website": null + "website": null, "created": 1387321261.572392, "modified": null, "likes": 3, - "dislikes": 0, + "dislikes": 0 } id : @@ -70,7 +70,7 @@ modified : List comments ------------- -List all publicely visible comments for thread `uri`: +List all publicly visible comments for thread `uri`: .. code-block:: text @@ -185,3 +185,16 @@ uri : returns an integer +Get Atom feed +------------- + +Get an Atom feed of comments for thread `uri`: + +.. code-block:: text + + GET /feed?uri=%2Fhello-world%2F + +uri : + URI to get comments for, required. + +Returns an XML document as the Atom feed. diff --git a/docs/docs/extras/deployment.rst b/docs/docs/extras/deployment.rst index 0fe190d..2aa53c6 100644 --- a/docs/docs/extras/deployment.rst +++ b/docs/docs/extras/deployment.rst @@ -213,7 +213,7 @@ Next, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer): WSGIServer(application).run() `Openshift `__ -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ With `Isso Openshift Deployment Kit`_, Isso can be installed on Open Shift with just one click. Make sure you already have installed ``rhc`` (`instructions`_) and completed the setup. diff --git a/docs/docs/install.rst b/docs/docs/install.rst index 6f537fe..eb2ef35 100644 --- a/docs/docs/install.rst +++ b/docs/docs/install.rst @@ -10,7 +10,7 @@ Debian/Ubuntu, Gentoo, Archlinux or Fedora, you can use :local: :depth: 1 - .. _install-interludium: +.. _install-interludium: Interludium: Python is not PHP ------------------------------ @@ -39,10 +39,10 @@ package manager. .. code-block:: sh # for Debian/Ubuntu - ~> sudo apt-get install python-setuptools python-virtualenv + ~> sudo apt-get install python-setuptools python-virtualenv python-dev # Fedora/Red Hat - ~> sudo yum install python-setuptools python-virtualenv + ~> sudo yum install python-setuptools python-virtualenv python-devel The next steps should be done as regular user, not as root (although possible but not recommended): @@ -79,7 +79,7 @@ Install from PyPi Requirements ^^^^^^^^^^^^ -- Python 2.6, 2.7 or 3.3+ (+ devel headers) +- Python 2.7 or 3.4+ (+ devel headers) - SQLite 3.3.8 or later - a working C compiler @@ -149,7 +149,18 @@ Prebuilt Packages * Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr repository. Built from Pypi, includes a systemctl unit script. -* Docker Image: https://registry.hub.docker.com/u/bl4n/isso/ +Build a Docker image +-------------------- + +You can get a Docker image by running ``docker build . -t +isso``. Assuming you have your configuration in ``/opt/isso``, you can +use the following command to spawn the Docker container: + +.. code-block:: sh + + ~> docker run -d --rm --name isso -p 127.0.0.1:8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso + +Then, you can use a reverse proxy to expose port 8080. Install from Source ------------------- @@ -157,7 +168,7 @@ Install from Source If you want to hack on Isso or track down issues, there's an alternate way to set up Isso. It requires a lot more dependencies and effort: -- Python 2.6, 2.7 or 3.3+ (+ devel headers) +- Python 2.7 or 3.4+ (+ devel headers) - Virtualenv - SQLite 3.3.8 or later - a working C compiler 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/__init__.py b/isso/__init__.py index 7795c4f..0af5b43 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -35,7 +35,8 @@ import sys if sys.argv[0].startswith("isso"): try: - import gevent.monkey; gevent.monkey.patch_all() + import gevent.monkey + gevent.monkey.patch_all() except ImportError: pass @@ -87,7 +88,8 @@ class Isso(object): self.conf = conf self.db = db.SQLite3(conf.get('general', 'dbpath'), conf) - self.signer = URLSafeTimedSerializer(self.db.preferences.get("session-key")) + self.signer = URLSafeTimedSerializer( + self.db.preferences.get("session-key")) self.markup = html.Markup(conf.section('markup')) self.hasher = hash.new(conf.section("hash")) @@ -122,7 +124,8 @@ class Isso(object): local.request = request local.host = wsgi.host(request.environ) - local.origin = origin(self.conf.getiter("general", "host"))(request.environ) + local.origin = origin(self.conf.getiter( + "general", "host"))(request.environ) adapter = self.urls.bind_to_environ(request.environ) @@ -136,7 +139,8 @@ class Isso(object): except HTTPException as e: return e except Exception: - logger.exception("%s %s", request.method, request.environ["PATH_INFO"]) + logger.exception("%s %s", request.method, + request.environ["PATH_INFO"]) return InternalServerError() else: return response @@ -181,18 +185,19 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False): if isso.conf.getboolean("server", "profile"): wrapper.append(partial(ProfilerMiddleware, - sort_by=("cumulative", ), restrictions=("isso/(?!lib)", 10))) + sort_by=("cumulative", ), restrictions=("isso/(?!lib)", 10))) wrapper.append(partial(SharedDataMiddleware, exports={ '/js': join(dirname(__file__), 'js/'), '/css': join(dirname(__file__), 'css/'), + '/img': join(dirname(__file__), 'img/'), '/demo': join(dirname(__file__), 'demo/') - })) + })) wrapper.append(partial(wsgi.CORSMiddleware, - origin=origin(isso.conf.getiter("general", "host")), - allowed=("Origin", "Referer", "Content-Type"), - exposed=("X-Set-Cookie", "Date"))) + origin=origin(isso.conf.getiter("general", "host")), + allowed=("Origin", "Referer", "Content-Type"), + exposed=("X-Set-Cookie", "Date"))) wrapper.extend([wsgi.SubURI, ProxyFix]) @@ -207,9 +212,10 @@ def main(): parser = ArgumentParser(description="a blog comment hosting service") subparser = parser.add_subparsers(help="commands", dest="command") - parser.add_argument('--version', action='version', version='%(prog)s ' + dist.version) + parser.add_argument('--version', action='version', + version='%(prog)s ' + dist.version) parser.add_argument("-c", dest="conf", default="/etc/isso.conf", - metavar="/etc/isso.conf", help="set configuration file") + metavar="/etc/isso.conf", help="set configuration file") imprt = subparser.add_parser('import', help="import Disqus XML export") imprt.add_argument("dump", metavar="FILE") @@ -220,10 +226,12 @@ def main(): imprt.add_argument("--empty-id", dest="empty_id", action="store_true", help="workaround for weird Disqus XML exports, #135") - serve = subparser.add_parser("run", help="run server") + # run Isso as stand-alone server + subparser.add_parser("run", help="run server") args = parser.parse_args() - conf = config.load(join(dist.location, dist.project_name, "defaults.ini"), args.conf) + conf = config.load( + join(dist.location, dist.project_name, "defaults.ini"), args.conf) if args.command == "import": conf.set("guard", "enabled", "off") diff --git a/isso/compat.py b/isso/compat.py index e12f268..c93270d 100644 --- a/isso/compat.py +++ b/isso/compat.py @@ -1,28 +1,24 @@ # -*- encoding: utf-8 -*- -import sys -PY2K = sys.version_info[0] == 2 - -if not PY2K: - - map, zip, filter = map, zip, filter - from functools import reduce - - iteritems = lambda dikt: iter(dikt.items()) - +try: + text_type = unicode # Python 2 + string_types = (str, unicode) + PY2K = True +except NameError: # Python 3 + PY2K = False text_type = str string_types = (str, ) +if not PY2K: buffer = memoryview + filter, map, zip = filter, map, zip + + def iteritems(dikt): return iter(dikt.items()) # noqa: E731 + from functools import reduce else: - - from itertools import imap, izip, ifilter - map, zip, filter = imap, izip, ifilter - reduce = reduce - - iteritems = lambda dikt: dikt.iteritems() - - text_type = unicode - string_types = (str, unicode) - buffer = buffer + from itertools import ifilter, imap, izip + filter, map, zip = ifilter, imap, izip + + def iteritems(dikt): return dikt.iteritems() # noqa: E731 + reduce = reduce diff --git a/isso/config.py b/isso/config.py index 234a0fe..488224b 100644 --- a/isso/config.py +++ b/isso/config.py @@ -17,11 +17,6 @@ from isso.compat import text_type as str logger = logging.getLogger("isso") -# Python 2.6 compatibility -def total_seconds(td): - return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6 - - def timedelta(string): """ Parse :param string: into :class:`datetime.timedelta`, you can use any @@ -101,7 +96,7 @@ class IssoParser(ConfigParser): try: return int(delta.total_seconds()) except AttributeError: - return int(total_seconds(delta)) + return int(delta.total_seconds()) def getlist(self, section, key): return list(map(str.strip, self.get(section, key).split(','))) @@ -128,8 +123,8 @@ def new(options=None): def load(default, user=None): # return set of (section, option) - setify = lambda cp: set((section, option) for section in cp.sections() - for option in cp.options(section)) + def setify(cp): return set((section, option) for section in cp.sections() + for option in cp.options(section)) parser = new() parser.read(default) diff --git a/isso/core.py b/isso/core.py index 368c8e3..9992df9 100644 --- a/isso/core.py +++ b/isso/core.py @@ -122,7 +122,8 @@ class uWSGIMixin(Mixin): self.cache = uWSGICache timedelta = conf.getint("moderation", "purge-after") - purge = lambda signum: self.db.comments.purge(timedelta) + + def purge(signum): return self.db.comments.purge(timedelta) uwsgi.register_signal(1, "", purge) uwsgi.add_timer(1, timedelta) diff --git a/isso/css/admin.css b/isso/css/admin.css new file mode 100644 index 0000000..c440528 --- /dev/null +++ b/isso/css/admin.css @@ -0,0 +1,134 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} +h1, h2, h3, h4, h5, h6 { + font-style: normal; + font-weight: normal; +} +input { + text-align: center; +} +.header::before, .header::after { + content: " "; + display: table; +} +.header::after { + clear: both; +} +.header::before, .header::after { + content: " "; + display: table; +} +.header { + margin-left: auto; + margin-right: auto; + max-width: 68em; + padding-bottom: 1em; + padding-top: 1em; +} +.header header { + display: block; + float: left; + font-weight: normal; + margin-right: 16.0363%; + width: 41.9818%; +} +.header header .logo { + float: left; + max-height: 60px; + padding-right: 12px; +} +.header header h1 { + font-size: 1.55em; + margin-bottom: 0.3em; +} +.header header h2 { + font-size: 1.05em; +} +.header a, .header a:visited { + color: #4d4c4c; + text-decoration: none; +} +.outer { + background-color: #eeeeee; + box-shadow: 0 0 0.5em #c0c0c0 inset; +} +.outer .filters::before, .outer .filters::after { + content: " "; + display: table; +} +.outer .filters::after { + clear: both; +} +.outer .filters::before, .outer .filters::after { + content: " "; + display: table; +} +.outer .filters { + margin-left: auto; + margin-right: auto; + max-width: 68em; + padding: 1em; +} + +a { + text-decoration: none; + color: #4d4c4c; +} +.label { + background-color: #ddd; + border: 1px solid #ccc; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + cursor: pointer; + line-height: 1.4em; + outline: 0 none; + padding: calc(0.6em - 1px); +} +.active { + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6) inset; +} +.label-valid { + background-color: #cfc; + border-color: #cfc; +} +.label-pending { + background-color: #ffc; + border-color: #ffc; +} +.mode { + float: left; +} +.pagination { + float: right; +} +.note .label { + margin: 9px; + padding: 3px; +} +#login { + margin-top: 40px; + text-align: center; + width: 100%; +} +.isso-comment-footer a { + cursor: pointer; +} +.thread-title { + margin-left: 3em; +} +.group { + float: left; + margin-left: 2em; +} +.editable { + border: 1px solid #aaa; + border-radius: 5px; + margin: 10px; + padding: 5px; +} +.hidden { + display: none; +} diff --git a/isso/css/isso.css b/isso/css/isso.css index 456740c..c0716f8 100644 --- a/isso/css/isso.css +++ b/isso/css/isso.css @@ -15,39 +15,42 @@ color: #555; font-weight: bold; } +#isso-thread > .isso-feedlink { + float: right; + padding-left: 1em; +} +#isso-thread > .isso-feedlink > a { + font-size: 0.8em; + vertical-align: bottom; +} #isso-thread .textarea { min-height: 58px; outline: 0; } #isso-thread .textarea.placeholder { - color: #AAA; + color: #757575; } -.isso-comment { +#isso-root .isso-comment { max-width: 68em; padding-top: 0.95em; margin: 0.95em auto; } -.isso-comment:not(:first-of-type), +#isso-root .preview .isso-comment { + padding-top: 0; + margin: 0; +} +#isso-root .isso-comment:not(:first-of-type), .isso-follow-up .isso-comment { border-top: 1px solid rgba(0, 0, 0, 0.1); } -.isso-comment > div.avatar, -.isso-postbox > .avatar { +.isso-comment > div.avatar { display: block; float: left; width: 7%; margin: 3px 15px 0 0; } -.isso-postbox > .avatar { - float: left; - margin: 5px 10px 0 5px; - width: 48px; - height: 48px; - overflow: hidden; -} -.isso-comment > div.avatar > svg, -.isso-postbox > .avatar > svg { +.isso-comment > div.avatar > svg { max-width: 48px; max-height: 48px; border: 1px solid rgba(0, 0, 0, 0.2); @@ -90,7 +93,8 @@ font-weight: bold; color: #555; } -.isso-comment > div.text-wrapper > .textarea-wrapper .textarea { +.isso-comment > div.text-wrapper > .textarea-wrapper .textarea, +.isso-comment > div.text-wrapper > .textarea-wrapper .preview { margin-top: 0.2em; } .isso-comment > div.text-wrapper > div.text p { @@ -108,7 +112,8 @@ font-size: 130%; font-weight: bold; } -.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea { +.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea, +.isso-comment > div.text-wrapper > div.textarea-wrapper .preview { width: 100%; border: 1px solid #f0f0f0; border-radius: 2px; @@ -119,10 +124,12 @@ color: gray !important; clear: left; } +.isso-feedlink, .isso-comment > div.text-wrapper > .isso-comment-footer a { font-weight: bold; text-decoration: none; } +.isso-feedlink:hover, .isso-comment > div.text-wrapper > .isso-comment-footer a:hover { color: #111111 !important; text-shadow: #aaaaaa 0 0 1px !important; @@ -152,6 +159,7 @@ .isso-postbox { max-width: 68em; margin: 0 auto 2em; + clear: right; } .isso-postbox > .form-wrapper { display: block; @@ -161,7 +169,8 @@ .isso-postbox > .form-wrapper > .auth-section .post-action { display: block; } -.isso-postbox > .form-wrapper .textarea { +.isso-postbox > .form-wrapper .textarea, +.isso-postbox > .form-wrapper .preview { margin: 0 0 .3em; padding: .4em .8em; border-radius: 3px; @@ -191,7 +200,7 @@ .isso-postbox > .form-wrapper > .auth-section .post-action { display: inline-block; float: right; - margin: 0; + margin: 0 0 0 5px; } .isso-postbox > .form-wrapper > .auth-section .post-action > input { padding: calc(.3em - 1px); @@ -209,6 +218,28 @@ .isso-postbox > .form-wrapper > .auth-section .post-action > input:active { background-color: #BBB; } +.isso-postbox > .form-wrapper .preview, +.isso-postbox > .form-wrapper input[name="edit"], +.isso-postbox.preview-mode > .form-wrapper input[name="preview"], +.isso-postbox.preview-mode > .form-wrapper .textarea { + display: none; +} +.isso-postbox.preview-mode > .form-wrapper .preview { + display: block; +} +.isso-postbox.preview-mode > .form-wrapper input[name="edit"] { + display: inline; +} +.isso-postbox > .form-wrapper .preview { + background-color: #f8f8f8; + background: repeating-linear-gradient( + -45deg, + #f8f8f8, + #f8f8f8 10px, + #fff 10px, + #fff 20px + ); +} @media screen and (max-width:600px) { .isso-postbox > .form-wrapper > .auth-section .input-wrapper { display: block; @@ -218,9 +249,4 @@ .isso-postbox > .form-wrapper > .auth-section .input-wrapper input { width: 100%; } - .isso-postbox > .form-wrapper > .auth-section .post-action { - display: block; - float: none; - text-align: right; - } } diff --git a/isso/db/__init__.py b/isso/db/__init__.py index 919137c..ecc7a44 100644 --- a/isso/db/__init__.py +++ b/isso/db/__init__.py @@ -98,10 +98,11 @@ class SQLite3: # limit max. nesting level to 1 if self.version == 2: - first = lambda rv: list(map(operator.itemgetter(0), rv)) + def first(rv): return list(map(operator.itemgetter(0), rv)) with sqlite3.connect(self.path) as con: - top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall()) + top = first(con.execute( + "SELECT id FROM comments WHERE parent IS NULL").fetchall()) flattened = defaultdict(set) for id in top: @@ -109,13 +110,15 @@ class SQLite3: ids = [id, ] while ids: - rv = first(con.execute("SELECT id FROM comments WHERE parent=?", (ids.pop(), ))) + rv = first(con.execute( + "SELECT id FROM comments WHERE parent=?", (ids.pop(), ))) ids.extend(rv) flattened[id].update(set(rv)) for id in flattened.keys(): for n in flattened[id]: - con.execute("UPDATE comments SET parent=? WHERE id=?", (id, n)) + con.execute( + "UPDATE comments SET parent=? WHERE id=?", (id, n)) con.execute('PRAGMA user_version = 3') logger.info("%i rows changed", con.total_changes) diff --git a/isso/db/comments.py b/isso/db/comments.py index 496a4e5..71a4aa5 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -19,8 +19,11 @@ class Comments: The tuple (tid, id) is unique and thus primary key. """ - fields = ['tid', 'id', 'parent', 'created', 'modified', 'mode', 'remote_addr', - 'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters'] + fields = ['tid', 'id', 'parent', 'created', 'modified', + 'mode', # status of the comment 1 = valid, 2 = pending, + # 4 = soft-deleted (cannot hard delete because of replies) + 'remote_addr', 'text', 'author', 'email', 'website', + 'likes', 'dislikes', 'voters'] def __init__(self, db): @@ -80,7 +83,7 @@ class Comments: """ self.db.execute([ 'UPDATE comments SET', - ','.join(key + '=' + '?' for key in data), + ','.join(key + '=' + '?' for key in data), 'WHERE id=?;'], list(data.values()) + [id]) @@ -91,19 +94,79 @@ class Comments: Search for comment :param:`id` and return a mapping of :attr:`fields` and values. """ - rv = self.db.execute('SELECT * FROM comments WHERE id=?', (id, )).fetchone() + rv = self.db.execute( + 'SELECT * FROM comments WHERE id=?', (id, )).fetchone() if rv: return dict(zip(Comments.fields, rv)) return None - def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None): + def count_modes(self): + """ + Return comment mode counts for admin + """ + comment_count = self.db.execute( + 'SELECT mode, COUNT(comments.id) FROM comments ' + 'GROUP BY comments.mode').fetchall() + return dict(comment_count) + + def fetchall(self, mode=5, after=0, parent='any', order_by='id', + limit=100, page=0, asc=1): + """ + Return comments for admin with :param:`mode`. + """ + fields_comments = ['tid', 'id', 'parent', 'created', 'modified', + 'mode', 'remote_addr', 'text', 'author', + 'email', 'website', 'likes', 'dislikes'] + fields_threads = ['uri', 'title'] + sql_comments_fields = ', '.join(['comments.' + f + for f in fields_comments]) + sql_threads_fields = ', '.join(['threads.' + f + for f in fields_threads]) + sql = ['SELECT ' + sql_comments_fields + ', ' + + sql_threads_fields + ' ' + 'FROM comments INNER JOIN threads ' + 'ON comments.tid=threads.id ' + 'WHERE comments.mode = ? '] + sql_args = [mode] + + if parent != 'any': + if parent is None: + sql.append('AND comments.parent IS NULL') + else: + sql.append('AND comments.parent=?') + sql_args.append(parent) + + # custom sanitization + if order_by not in ['id', 'created', 'modified', 'likes', 'dislikes', 'tid']: + sql.append('ORDER BY ') + sql.append("comments.created") + if not asc: + sql.append(' DESC') + else: + sql.append('ORDER BY ') + sql.append('comments.' + order_by) + if not asc: + sql.append(' DESC') + sql.append(", comments.created") + + if limit: + sql.append('LIMIT ?,?') + sql_args.append(page * limit) + sql_args.append(limit) + + rv = self.db.execute(sql, sql_args).fetchall() + for item in rv: + yield dict(zip(fields_comments + fields_threads, item)) + + def fetch(self, uri, mode=5, after=0, parent='any', + order_by='id', asc=1, limit=None): """ Return comments for :param:`uri` with :param:`mode`. """ - sql = [ 'SELECT comments.* FROM comments INNER JOIN threads ON', - ' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?', - ' AND comments.created>?'] + sql = ['SELECT comments.* FROM comments INNER JOIN threads ON', + ' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?', + ' AND comments.created>?'] sql_args = [uri, mode, mode, after] @@ -119,7 +182,8 @@ class Comments: order_by = 'id' sql.append('ORDER BY ') sql.append(order_by) - sql.append(' ASC') + if not asc: + sql.append(' DESC') if limit: sql.append('LIMIT ?') @@ -155,7 +219,8 @@ class Comments: In the second case this comment can be safely removed without any side effects.""" - refs = self.db.execute('SELECT * FROM comments WHERE parent=?', (id, )).fetchone() + refs = self.db.execute( + 'SELECT * FROM comments WHERE parent=?', (id, )).fetchone() if refs is None: self.db.execute('DELETE FROM comments WHERE id=?', (id, )) @@ -165,7 +230,8 @@ class Comments: self.db.execute('UPDATE comments SET text=? WHERE id=?', ('', id)) self.db.execute('UPDATE comments SET mode=? WHERE id=?', (4, id)) for field in ('author', 'website'): - self.db.execute('UPDATE comments SET %s=? WHERE id=?' % field, (None, id)) + self.db.execute('UPDATE comments SET %s=? WHERE id=?' % + field, (None, id)) self._remove_stale() return self.get(id) diff --git a/isso/db/preferences.py b/isso/db/preferences.py index 28936e4..3eb12a3 100644 --- a/isso/db/preferences.py +++ b/isso/db/preferences.py @@ -24,7 +24,7 @@ class Preferences: def get(self, key, default=None): rv = self.db.execute( - 'SELECT value FROM preferences WHERE key=?', (key, )).fetchone() + 'SELECT value FROM preferences WHERE key=?', (key, )).fetchone() if rv is None: return default diff --git a/isso/db/spam.py b/isso/db/spam.py index d198f6a..5069229 100644 --- a/isso/db/spam.py +++ b/isso/db/spam.py @@ -50,7 +50,7 @@ class Guard: return False, "%i direct responses to %s" % (len(rv), uri) # block replies to self unless :param:`reply-to-self` is enabled - elif self.conf.getboolean("reply-to-self") == False: + elif self.conf.getboolean("reply-to-self") is False: rv = self.db.execute([ 'SELECT id FROM comments WHERE' ' remote_addr = ?', diff --git a/isso/db/threads.py b/isso/db/threads.py index bb7067b..4f0b476 100644 --- a/isso/db/threads.py +++ b/isso/db/threads.py @@ -26,5 +26,6 @@ class Threads(object): return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone()) def new(self, uri, title): - self.db.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title)) + self.db.execute( + "INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title)) return self[uri] diff --git a/isso/dispatch.py b/isso/dispatch.py index bf3a163..7c8bb14 100644 --- a/isso/dispatch.py +++ b/isso/dispatch.py @@ -25,7 +25,8 @@ class Dispatcher(DispatcherMiddleware): def __init__(self, *confs): self.isso = {} - default = os.path.join(dist.location, dist.project_name, "defaults.ini") + default = os.path.join( + dist.location, dist.project_name, "defaults.ini") for i, path in enumerate(confs): conf = config.load(default, path) @@ -45,7 +46,8 @@ class Dispatcher(DispatcherMiddleware): return super(Dispatcher, self).__call__(environ, start_response) def default(self, environ, start_response): - resp = Response("\n".join(self.isso.keys()), 404, content_type="text/plain") + resp = Response("\n".join(self.isso.keys()), + 404, content_type="text/plain") return resp(environ, start_response) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 3ba708b..2b4a81c 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -37,6 +37,12 @@ class SMTP(object): self.isso = isso self.conf = isso.conf.section("smtp") + gh = isso.conf.get("general", "host") + if type(gh) == str: + self.general_host = gh + #if gh is not a string then gh is a list + else: + self.general_host = gh[0] # test SMTP connectivity try: @@ -58,7 +64,8 @@ class SMTP(object): uwsgi.spooler = spooler def __enter__(self): - klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP) + klass = (smtplib.SMTP_SSL if self.conf.get( + 'security') == 'ssl' else smtplib.SMTP) self.client = klass(host=self.conf.get('host'), port=self.conf.getint('port'), timeout=self.conf.getint('timeout')) @@ -104,10 +111,11 @@ class SMTP(object): rv.write("User's URL: %s\n" % comment["website"]) rv.write("IP address: %s\n" % comment["remote_addr"]) - rv.write("Link to comment: %s\n" % (local("origin") + thread["uri"] + "#isso-%i" % comment["id"])) + rv.write("Link to comment: %s\n" % + (local("origin") + thread["uri"] + "#isso-%i" % comment["id"])) rv.write("\n") - uri = local("host") + "/id/%i" % comment["id"] + uri = self.general_host + "/id/%i" % comment["id"] key = self.isso.sign(comment["id"]) rv.write("---\n") @@ -173,7 +181,8 @@ class Stdout(object): logger.info("comment created: %s", json.dumps(comment)) def _edit_comment(self, comment): - logger.info('comment %i edited: %s', comment["id"], json.dumps(comment)) + logger.info('comment %i edited: %s', + comment["id"], json.dumps(comment)) def _delete_comment(self, id): logger.info('comment %i deleted', id) diff --git a/isso/img/isso.svg b/isso/img/isso.svg new file mode 100644 index 0000000..1aba2dd --- /dev/null +++ b/isso/img/isso.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file 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 2a4cde1..25af27c 100644 --- a/isso/js/app/config.js +++ b/isso/js/app/config.js @@ -16,7 +16,8 @@ define(function() { "avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6", "#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "), "vote": true, - "vote-levels": null + "vote-levels": null, + "feed": false }; var js = document.getElementsByTagName("script"); 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.js b/isso/js/app/i18n.js index 98141f6..9f44b5b 100644 --- a/isso/js/app/i18n.js +++ b/isso/js/app/i18n.js @@ -1,9 +1,9 @@ -define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de", - "app/i18n/en", "app/i18n/fa", "app/i18n/fi", "app/i18n/fr", - "app/i18n/hr", "app/i18n/ru", "app/i18n/it", "app/i18n/eo", - "app/i18n/sv", "app/i18n/nl", "app/i18n/el_GR", "app/i18n/es", - "app/i18n/vi", "app/i18n/zh_CN"], - function(config, bg, cs, de, en, fa, fi, fr, hr, ru, it, eo, sv, nl, el, es, vi, zh) { +define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da", + "app/i18n/de", "app/i18n/en", "app/i18n/fa", "app/i18n/fi", + "app/i18n/fr", "app/i18n/hr", "app/i18n/hu", "app/i18n/ru", "app/i18n/it", + "app/i18n/eo", "app/i18n/sv", "app/i18n/nl", "app/i18n/el_GR", + "app/i18n/es", "app/i18n/vi", "app/i18n/zh_CN", "app/i18n/zh_CN", "app/i18n/zh_TW"], + function(config, bg, cs, da, de, en, fa, fi, fr, hr, hu, ru, it, eo, sv, nl, el, es, vi, zh, zh_CN, zh_TW) { "use strict"; @@ -11,6 +11,7 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de", switch (lang) { case "bg": case "cs": + case "da": case "de": case "el": case "en": @@ -19,11 +20,14 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de", case "fa": case "fi": case "hr": + case "hu": case "it": case "sv": case "nl": case "vi": case "zh": + case "zh_CN": + case "zh_TW": return function(msgs, n) { return msgs[n === 1 ? 0 : 1]; }; @@ -55,7 +59,9 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de", } var catalogue = { + bg: bg, cs: cs, + da: da, de: de, el: el, en: en, @@ -66,11 +72,14 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/de", fr: fr, it: it, hr: hr, + hu: hu, ru: ru, sv: sv, nl: nl, vi: vi, - zh: zh + zh: zh_CN, + zh_CN: zh_CN, + zh_TW: zh_TW }; var plural = pluralforms(lang); 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 new file mode 100644 index 0000000..d97cd4c --- /dev/null +++ b/isso/js/app/i18n/da.js @@ -0,0 +1,32 @@ +define({ + "postbox-text": "Type Comment Here (at least 3 chars)", + "postbox-author": "Name (optional)", + "postbox-email": "E-mail (optional)", + "postbox-website": "Website (optional)", + "postbox-preview": "Eksempel", + "postbox-edit": "Rediger", + "postbox-submit": "Submit", + + "num-comments": "One Comment\n{{ n }} Comments", + "no-comments": "Ingen kommentarer endnu", + + "comment-reply": "Svar", + "comment-edit": "Rediger", + "comment-save": "Gem", + "comment-delete": "Fjern", + "comment-confirm": "Bekræft", + "comment-close": "Luk", + "comment-cancel": "Annuller", + "comment-deleted": "Kommentar slettet.", + "comment-queued": "Kommentar i kø for moderation.", + "comment-anonymous": "Anonym", + "comment-hidden": "{{ n }} Skjult", + + "date-now": "lige nu", + "date-minute": "et minut siden\n{{ n }} minutter siden", + "date-hour": "en time siden\n{{ n }} timer siden", + "date-day": "Igår\n{{ n }} dage siden", + "date-week": "sidste uge\n{{ n }} uger siden", + "date-month": "sidste måned\n{{ n }} måneder siden", + "date-year": "sidste år\n{{ n }} år siden" +}); diff --git a/isso/js/app/i18n/de.js b/isso/js/app/i18n/de.js index 5ac0610..04e1739 100644 --- a/isso/js/app/i18n/de.js +++ b/isso/js/app/i18n/de.js @@ -1,11 +1,13 @@ define({ - "postbox-text": "Kommentar hier eintippen (mindestens 3 Zeichen)", + "postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)", "postbox-author": "Name (optional)", "postbox-email": "Email (optional)", "postbox-website": "Website (optional)", + "postbox-preview": "Vorschau", + "postbox-edit": "Bearbeiten", "postbox-submit": "Abschicken", "num-comments": "1 Kommentar\n{{ n }} Kommentare", - "no-comments": "Keine Kommentare bis jetzt", + "no-comments": "Bisher keine Kommentare", "comment-reply": "Antworten", "comment-edit": "Bearbeiten", "comment-save": "Speichern", @@ -17,7 +19,7 @@ define({ "comment-queued": "Kommentar muss noch freigeschaltet werden.", "comment-anonymous": "Anonym", "comment-hidden": "{{ n }} versteckt", - "date-now": "eben jetzt", + "date-now": "eben gerade", "date-minute": "vor einer Minute\nvor {{ n }} Minuten", "date-hour": "vor einer Stunde\nvor {{ n }} Stunden", "date-day": "Gestern\nvor {{ n }} Tagen", 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 ec4b4d0..fd110fe 100644 --- a/isso/js/app/i18n/en.js +++ b/isso/js/app/i18n/en.js @@ -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", 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 e29d024..32e0ed6 100644 --- a/isso/js/app/i18n/fr.js +++ b/isso/js/app/i18n/fr.js @@ -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", 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 new file mode 100644 index 0000000..e06c513 --- /dev/null +++ b/isso/js/app/i18n/hu.js @@ -0,0 +1,29 @@ +define({ + "postbox-text": "Hozzászólást ide írd be (legalább 3 betűt)", + "postbox-author": "Név (nem kötelező)", + "postbox-email": "Email (nem kötelező)", + "postbox-website": "Website (nem kötelező)", + "postbox-preview": "Előnézet", + "postbox-edit": "Szerekesztés", + "postbox-submit": "Elküld", + "num-comments": "Egy hozzászólás\n{{ n }} hozzászólás", + "no-comments": "Eddig nincs hozzászólás", + "comment-reply": "Válasz", + "comment-edit": "Szerekesztés", + "comment-save": "Mentés", + "comment-delete": "Törlés", + "comment-confirm": "Megerősít", + "comment-close": "Bezár", + "comment-cancel": "Törlés", + "comment-deleted": "Hozzászólás törölve.", + "comment-queued": "A hozzászólást előbb ellenőrizzük.", + "comment-anonymous": "Névtelen", + "comment-hidden": "{{ n }} rejtve", + "date-now": "pillanatokkal ezelőtt", + "date-minute": "egy perce\n{{ n }} perce", + "date-hour": "egy órája\n{{ n }} órája", + "date-day": "tegnap\n{{ n }} napja", + "date-week": "múlt héten\n{{ n }} hete", + "date-month": "múlt hónapban\n{{ n }} hónapja", + "date-year": "tavaly\n{{ n }} éve" +}); 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 7b908a1..54889e9 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) @@ -41,8 +42,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n", // email is not optional if this config parameter is set if (config["require-email"]) { - $("[name='email']", el).placeholder = - $("[name='email']", el).placeholder.replace(/ \(.*\)/, ""); + $("[name='email']", el).setAttribute("placeholder", + $("[name='email']", el).getAttribute("placeholder").replace(/ \(.*\)/, "")); } // author is not optional if this config parameter is set @@ -51,9 +52,27 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n", $("[name='author']", el).placeholder.replace(/ \(.*\)/, ""); } + // preview function + $("[name='preview']", el).on("click", function() { + api.preview(utils.text($(".textarea", el).innerHTML)).then( + function(html) { + $(".preview .text", el).innerHTML = html; + el.classList.add('preview-mode'); + }); + }); + + // edit function + var edit = function() { + $(".preview .text", el).innerHTML = ''; + el.classList.remove('preview-mode'); + }; + $("[name='edit']", el).on("click", edit); + $(".preview", el).on("click", edit); + // submit form, initialize optional fields with `null` and reset form. // If replied to a comment, remove form completely. $("[type=submit]", el).on("click", function() { + edit(); if (! el.validate()) { return; } 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/lib/identicons.js b/isso/js/app/lib/identicons.js index 1dcb567..f865c92 100644 --- a/isso/js/app/lib/identicons.js +++ b/isso/js/app/lib/identicons.js @@ -47,7 +47,7 @@ define(["app/lib/promise", "app/config"], function(Q, config) { } Q.when(key, function(key) { - var hash = pad((parseInt(key, 16) % Math.pow(2, 18)).toString(2), 18), + var hash = pad((parseInt(key.substr(-16), 16) % Math.pow(2, 18)).toString(2), 18), index = 0; svg.setAttribute("data-hash", key); diff --git a/isso/js/app/text/postbox.jade b/isso/js/app/text/postbox.jade index 0a85ae1..908326b 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,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')) diff --git a/isso/js/app/utils.js b/isso/js/app/utils.js index f971770..d8f3970 100644 --- a/isso/js/app/utils.js +++ b/isso/js/app/utils.js @@ -20,8 +20,8 @@ define(["app/i18n"], function(i18n) { secs = 0; } - var mins = Math.ceil(secs / 60), hours = Math.ceil(mins / 60), - days = Math.ceil(hours / 24); + var mins = Math.floor(secs / 60), hours = Math.floor(mins / 60), + days = Math.floor(hours / 24); return secs <= 45 && i18n.translate("date-now") || secs <= 90 && i18n.pluralize("date-minute", 1) || @@ -31,11 +31,11 @@ define(["app/i18n"], function(i18n) { hours <= 36 && i18n.pluralize("date-day", 1) || days <= 5 && i18n.pluralize("date-day", days) || days <= 8 && i18n.pluralize("date-week", 1) || - days <= 21 && i18n.pluralize("date-week", Math.ceil(days / 7)) || + days <= 21 && i18n.pluralize("date-week", Math.floor(days / 7)) || days <= 45 && i18n.pluralize("date-month", 1) || - days <= 345 && i18n.pluralize("date-month", Math.ceil(days / 30)) || + days <= 345 && i18n.pluralize("date-month", Math.floor(days / 30)) || days <= 547 && i18n.pluralize("date-year", 1) || - i18n.pluralize("date-year", Math.ceil(days / 365.25)); + i18n.pluralize("date-year", Math.floor(days / 365.25)); }; var HTMLEntity = { 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 f53341a..f6297b7 100644 --- a/isso/migrate.py +++ b/isso/migrate.py @@ -30,6 +30,7 @@ from xml.etree import ElementTree logger = logging.getLogger("isso") + def strip(val): if isinstance(val, string_types): return val.strip() @@ -82,7 +83,8 @@ class Disqus(object): remap = dict() if path not in self.db.threads: - self.db.threads.new(path, thread.find(Disqus.ns + 'title').text.strip()) + self.db.threads.new(path, thread.find( + Disqus.ns + 'title').text.strip()) for item in sorted(posts, key=lambda k: k['created']): @@ -112,9 +114,11 @@ class Disqus(object): } if post.find(Disqus.ns + 'parent') is not None: - item['dsq:parent'] = post.find(Disqus.ns + 'parent').attrib.get(Disqus.internals + 'id') + item['dsq:parent'] = post.find( + Disqus.ns + 'parent').attrib.get(Disqus.internals + 'id') - res[post.find('%sthread' % Disqus.ns).attrib.get(Disqus.internals + 'id')].append(item) + res[post.find('%sthread' % Disqus.ns).attrib.get( + Disqus.internals + 'id')].append(item) progress = Progress(len(tree.findall(Disqus.ns + 'thread'))) for i, thread in enumerate(tree.findall(Disqus.ns + 'thread')): @@ -135,7 +139,8 @@ class Disqus(object): progress.finish("{0} threads, {1} comments".format( len(self.threads), len(self.comments))) - orphans = set(map(lambda e: e.attrib.get(Disqus.internals + "id"), tree.findall(Disqus.ns + "post"))) - self.comments + orphans = set(map(lambda e: e.attrib.get(Disqus.internals + "id"), + tree.findall(Disqus.ns + "post"))) - self.comments if orphans and not self.threads: print("Isso couldn't import any thread, try again with --empty-id") elif orphans: @@ -258,22 +263,22 @@ def autodetect(peek): def dispatch(type, db, dump, empty_id=False): - if db.execute("SELECT * FROM comments").fetchone(): - if input("Isso DB is not empty! Continue? [y/N]: ") not in ("y", "Y"): - raise SystemExit("Abort.") + if db.execute("SELECT * FROM comments").fetchone(): + if input("Isso DB is not empty! Continue? [y/N]: ") not in ("y", "Y"): + raise SystemExit("Abort.") - if type == "disqus": - cls = Disqus - elif type == "wordpress": - cls = WordPress - else: - with io.open(dump, encoding="utf-8") as fp: - cls = autodetect(fp.read(io.DEFAULT_BUFFER_SIZE)) + if type == "disqus": + cls = Disqus + elif type == "wordpress": + cls = WordPress + else: + with io.open(dump, encoding="utf-8") as fp: + cls = autodetect(fp.read(io.DEFAULT_BUFFER_SIZE)) - if cls is None: - raise SystemExit("Unknown format, abort.") + if cls is None: + raise SystemExit("Unknown format, abort.") - if cls is Disqus: - cls = functools.partial(cls, empty_id=empty_id) + if cls is Disqus: + cls = functools.partial(cls, empty_id=empty_id) - cls(db, dump).migrate() + cls(db, dump).migrate() diff --git a/isso/templates/admin.html b/isso/templates/admin.html new file mode 100644 index 0000000..484a949 --- /dev/null +++ b/isso/templates/admin.html @@ -0,0 +1,253 @@ + + + Isso admin + + + + + +
+ +
+
+ +
+ Group by thread: +
+ +
+
+ Order: + {% for order in ['id', 'created', 'modified', 'likes', 'dislikes'] %} + + + {{ order }} + {% if order == order_by %} + {% if asc %} ↑ {% else %} ↓ {% endif %} + {% else %} + ↓ + {% endif %} + + + {% endfor %} +
+
+
+ {% set thread_id = "no_id" %} + {% for comment in comments %} + {% if order_by == "tid" %} + {% if thread_id != comment.tid %} + {% set thread_id = comment.tid %} +

{{comment.title}} ({{comment.uri}})

+ {% endif %} + {% endif %} +
+ {% if conf.avatar %} +
+ svg(data-hash='#{{comment.hash}}') +
+ {% endif %} +
+
+ {% if order_by != "tid" %} +
Thread: {{comment.title}} ({{comment.uri}})

+ {% endif %} + {% if comment.author %} + {{comment.author}} + {% else %} + Anonymous + {% endif %} + {% if comment.email %} + ({{comment.email}} ) + {% else %} + + {% endif %} + {% if comment.website %} + ({{comment.website}} open) + {% else %} + + {% endif %} + + + + {% if comment.mode == 1 %} + Valid + {% elif comment.mode == 2 %} + Pending + {% elif comment.mode == 4 %} + Staled + {% endif %} + +
+
+ {% if comment.mode == 4 %} + HIDDEN. Original text:
+ {% endif %} +
{{comment.text}}
+
+ +
+
+ {% endfor %} +
+
+ + diff --git a/isso/templates/login.html b/isso/templates/login.html new file mode 100644 index 0000000..a9aa888 --- /dev/null +++ b/isso/templates/login.html @@ -0,0 +1,30 @@ + + + Isso admin + + + + +
+ +
+
+ Administration secured by password: +
+ +
+
+
+
+ + diff --git a/isso/tests/fixtures.py b/isso/tests/fixtures.py index 710d0d2..303e8bb 100644 --- a/isso/tests/fixtures.py +++ b/isso/tests/fixtures.py @@ -37,5 +37,7 @@ class Dummy: pass -curl = lambda method, host, path: Dummy() -loads = lambda data: json.loads(data.decode('utf-8')) +def curl(method, host, path): return Dummy() + + +def loads(data): return json.loads(data.decode('utf-8')) diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index b6fb393..a4ae5a5 100644 --- a/isso/tests/test_comments.py +++ b/isso/tests/test_comments.py @@ -4,12 +4,9 @@ from __future__ import unicode_literals import os import json +import re import tempfile - -try: - import unittest2 as unittest -except ImportError: - import unittest +import unittest try: from urllib.parse import urlencode @@ -36,6 +33,7 @@ class TestComments(unittest.TestCase): conf.set("general", "dbpath", self.path) conf.set("guard", "enabled", "off") conf.set("hash", "algorithm", "none") + self.conf = conf class App(Isso, core.Mixin): pass @@ -54,7 +52,8 @@ class TestComments(unittest.TestCase): def testGet(self): - self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'})) + self.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Lorem ipsum ...'})) r = self.get('/id/1') self.assertEqual(r.status_code, 200) @@ -65,7 +64,8 @@ class TestComments(unittest.TestCase): def testCreate(self): - rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'})) + rv = self.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Lorem ipsum ...'})) self.assertEqual(rv.status_code, 201) self.assertIn("Set-Cookie", rv.headers) @@ -77,7 +77,8 @@ class TestComments(unittest.TestCase): def textCreateWithNonAsciiText(self): - rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Здравствуй, мир!'})) + rv = self.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Здравствуй, мир!'})) self.assertEqual(rv.status_code, 201) rv = loads(rv.data) @@ -109,14 +110,16 @@ class TestComments(unittest.TestCase): def testCreateInvalidParent(self): self.post('/new?uri=test', data=json.dumps({'text': '...'})) - self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1})) - invalid = self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 2})) + self.post('/new?uri=test', + data=json.dumps({'text': '...', 'parent': 1})) + invalid = self.post( + '/new?uri=test', data=json.dumps({'text': '...', 'parent': 2})) self.assertEqual(loads(invalid.data)["parent"], 1) def testVerifyFields(self): - verify = lambda comment: comments.API.verify(comment)[0] + def verify(comment): return comments.API.verify(comment)[0] # text is missing self.assertFalse(verify({})) @@ -132,10 +135,12 @@ class TestComments(unittest.TestCase): # email/website length self.assertTrue(verify({"text": "...", "email": "*"*254})) - self.assertTrue(verify({"text": "...", "website": "google.de/" + "a"*128})) + self.assertTrue( + verify({"text": "...", "website": "google.de/" + "a"*128})) self.assertFalse(verify({"text": "...", "email": "*"*1024})) - self.assertFalse(verify({"text": "...", "website": "google.de/" + "*"*1024})) + self.assertFalse( + verify({"text": "...", "website": "google.de/" + "*"*1024})) # valid website url self.assertTrue(comments.isurl("example.tld")) @@ -143,7 +148,8 @@ class TestComments(unittest.TestCase): self.assertTrue(comments.isurl("https://example.tld")) self.assertTrue(comments.isurl("https://example.tld:1337/")) self.assertTrue(comments.isurl("https://example.tld:1337/foobar")) - self.assertTrue(comments.isurl("https://example.tld:1337/foobar?p=1#isso-thread")) + self.assertTrue(comments.isurl( + "https://example.tld:1337/foobar?p=1#isso-thread")) self.assertFalse(comments.isurl("ftp://example.tld/")) self.assertFalse(comments.isurl("tel:+1234567890")) @@ -153,7 +159,8 @@ class TestComments(unittest.TestCase): def testGetInvalid(self): self.assertEqual(self.get('/?uri=%2Fpath%2F&id=123').status_code, 404) - self.assertEqual(self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404) + self.assertEqual( + self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404) self.assertEqual(self.get('/?uri=?uri=%foo%2F').status_code, 404) def testGetLimited(self): @@ -170,7 +177,8 @@ class TestComments(unittest.TestCase): def testGetNested(self): self.post('/new?uri=test', data=json.dumps({'text': '...'})) - self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1})) + self.post('/new?uri=test', + data=json.dumps({'text': '...', 'parent': 1})) r = self.get('/?uri=test&parent=1') self.assertEqual(r.status_code, 200) @@ -182,7 +190,8 @@ class TestComments(unittest.TestCase): self.post('/new?uri=test', data=json.dumps({'text': '...'})) for i in range(20): - self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1})) + self.post('/new?uri=test', + data=json.dumps({'text': '...', 'parent': 1})) r = self.get('/?uri=test&parent=1&limit=10') self.assertEqual(r.status_code, 200) @@ -192,7 +201,8 @@ class TestComments(unittest.TestCase): def testUpdate(self): - self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'})) + self.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Lorem ipsum ...'})) self.put('/id/1', data=json.dumps({ 'text': 'Hello World', 'author': 'me', 'website': 'http://example.com/'})) @@ -207,7 +217,8 @@ class TestComments(unittest.TestCase): def testDelete(self): - self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'})) + self.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Lorem ipsum ...'})) r = self.delete('/id/1') self.assertEqual(r.status_code, 200) self.assertEqual(loads(r.data), None) @@ -217,7 +228,8 @@ class TestComments(unittest.TestCase): client = JSONClient(self.app, Response) client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'})) - client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First', 'parent': 1})) + client.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'First', 'parent': 1})) r = client.delete('/id/1') self.assertEqual(r.status_code, 200) @@ -246,8 +258,10 @@ class TestComments(unittest.TestCase): client = JSONClient(self.app, Response) client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'})) - client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Second', 'parent': 1})) - client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Third', 'parent': 1})) + client.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Second', 'parent': 1})) + client.post('/new?uri=%2Fpath%2F', + data=json.dumps({'text': 'Third', 'parent': 1})) client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Last'})) client.delete('/id/1') @@ -265,10 +279,11 @@ class TestComments(unittest.TestCase): for path in paths: self.assertEqual(self.post('/new?' + urlencode({'uri': path}), - data=json.dumps({'text': '...'})).status_code, 201) + data=json.dumps({'text': '...'})).status_code, 201) for i, path in enumerate(paths): - self.assertEqual(self.get('/?' + urlencode({'uri': path})).status_code, 200) + self.assertEqual( + self.get('/?' + urlencode({'uri': path})).status_code, 200) self.assertEqual(self.get('/id/%i' % (i + 1)).status_code, 200) def testDeleteAndCreateByDifferentUsersButSamePostId(self): @@ -287,7 +302,8 @@ class TestComments(unittest.TestCase): a = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Aaa"})) b = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Bbb"})) - c = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Ccc", "email": "..."})) + c = self.post('/new?uri=%2Fpath%2F', + data=json.dumps({"text": "Ccc", "email": "..."})) a = loads(a.data) b = loads(b.data) @@ -299,7 +315,8 @@ class TestComments(unittest.TestCase): def testVisibleFields(self): - rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "...", "invalid": "field"})) + rv = self.post('/new?uri=%2Fpath%2F', + data=json.dumps({"text": "...", "invalid": "field"})) self.assertEqual(rv.status_code, 201) rv = loads(rv.data) @@ -310,6 +327,36 @@ class TestComments(unittest.TestCase): self.assertListEqual(list(rv.keys()), []) + def testNoFeed(self): + rv = self.get('/feed?uri=%2Fpath%2Fnothing') + self.assertEqual(rv.status_code, 404) + + def testFeedEmpty(self): + self.conf.set("rss", "base", "https://example.org") + + rv = self.get('/feed?uri=%2Fpath%2Fnothing') + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.headers['ETag'], '"empty"') + data = rv.data.decode('utf-8') + self.assertEqual(data, """ +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) @@ -338,7 +385,8 @@ class TestComments(unittest.TestCase): for uri, count in iteritems(expected): for _ in range(count): - self.post('/new?uri=%s' % uri, data=json.dumps({"text": "..."})) + self.post('/new?uri=%s' % + uri, data=json.dumps({"text": "..."})) rv = self.post('/count', data=json.dumps(list(expected.keys()))) self.assertEqual(loads(rv.data), list(expected.values())) @@ -367,22 +415,26 @@ class TestComments(unittest.TestCase): self.post('/new?uri=%2F', data=json.dumps({"text": "..."})) # no header is fine (default for XHR) - self.assertEqual(self.post('/id/1/dislike', content_type="").status_code, 200) + self.assertEqual( + self.post('/id/1/dislike', content_type="").status_code, 200) # x-www-form-urlencoded is definitely not RESTful - self.assertEqual(self.post('/id/1/dislike', content_type=form).status_code, 403) + self.assertEqual( + self.post('/id/1/dislike', content_type=form).status_code, 403) self.assertEqual(self.post('/new?uri=%2F', data=json.dumps({"text": "..."}), - content_type=form).status_code, 403) + content_type=form).status_code, 403) # just for the record - self.assertEqual(self.post('/id/1/dislike', content_type=js).status_code, 200) + self.assertEqual( + self.post('/id/1/dislike', content_type=js).status_code, 200) def testPreview(self): - response = self.post('/preview', data=json.dumps({'text': 'This is **mark***down*'})) + response = self.post( + '/preview', data=json.dumps({'text': 'This is **mark***down*'})) self.assertEqual(response.status_code, 200) rv = loads(response.data) - self.assertEqual(rv["text"], '

This is markdown

') - + self.assertEqual( + rv["text"], '

This is markdown

') class TestModeratedComments(unittest.TestCase): @@ -407,7 +459,8 @@ class TestModeratedComments(unittest.TestCase): def testAddComment(self): - rv = self.client.post('/new?uri=test', data=json.dumps({"text": "..."})) + rv = self.client.post( + '/new?uri=test', data=json.dumps({"text": "..."})) self.assertEqual(rv.status_code, 202) self.assertEqual(self.client.get('/id/1').status_code, 200) diff --git a/isso/tests/test_config.py b/isso/tests/test_config.py index dc40e65..2fd1b4d 100644 --- a/isso/tests/test_config.py +++ b/isso/tests/test_config.py @@ -1,10 +1,6 @@ # -*- encoding: utf-8 -*- -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest import io from isso import config diff --git a/isso/tests/test_cors.py b/isso/tests/test_cors.py index 4cfc20d..c1d22a0 100644 --- a/isso/tests/test_cors.py +++ b/isso/tests/test_cors.py @@ -1,10 +1,7 @@ from __future__ import unicode_literals -try: - import unittest2 as unittest -except ImportError: - import unittest +import unittest from werkzeug.test import Client from werkzeug.wrappers import Response @@ -22,31 +19,37 @@ class CORSTest(unittest.TestCase): def test_simple(self): app = CORSMiddleware(hello_world, - origin=origin([ - "https://example.tld/", - "http://example.tld/", - ]), - allowed=("Foo", "Bar"), exposed=("Spam", )) + origin=origin([ + "https://example.tld/", + "http://example.tld/", + ]), + allowed=("Foo", "Bar"), exposed=("Spam", )) client = Client(app, Response) rv = client.get("/", headers={"Origin": "https://example.tld"}) - self.assertEqual(rv.headers["Access-Control-Allow-Origin"], "https://example.tld") - self.assertEqual(rv.headers["Access-Control-Allow-Credentials"], "true") - self.assertEqual(rv.headers["Access-Control-Allow-Methods"], "HEAD, GET, POST, PUT, DELETE") - self.assertEqual(rv.headers["Access-Control-Allow-Headers"], "Foo, Bar") + self.assertEqual( + rv.headers["Access-Control-Allow-Origin"], "https://example.tld") + self.assertEqual( + rv.headers["Access-Control-Allow-Credentials"], "true") + self.assertEqual( + rv.headers["Access-Control-Allow-Methods"], "HEAD, GET, POST, PUT, DELETE") + self.assertEqual( + rv.headers["Access-Control-Allow-Headers"], "Foo, Bar") self.assertEqual(rv.headers["Access-Control-Expose-Headers"], "Spam") a = client.get("/", headers={"Origin": "http://example.tld"}) - self.assertEqual(a.headers["Access-Control-Allow-Origin"], "http://example.tld") + self.assertEqual( + a.headers["Access-Control-Allow-Origin"], "http://example.tld") b = client.get("/", headers={"Origin": "http://example.tld"}) - self.assertEqual(b.headers["Access-Control-Allow-Origin"], "http://example.tld") + self.assertEqual( + b.headers["Access-Control-Allow-Origin"], "http://example.tld") c = client.get("/", headers={"Origin": "http://foo.other"}) - self.assertEqual(c.headers["Access-Control-Allow-Origin"], "https://example.tld") - + self.assertEqual( + c.headers["Access-Control-Allow-Origin"], "https://example.tld") def test_preflight(self): @@ -54,10 +57,12 @@ class CORSTest(unittest.TestCase): allowed=("Foo", ), exposed=("Bar", )) client = Client(app, Response) - rv = client.open(method="OPTIONS", path="/", headers={"Origin": "http://example.tld"}) + rv = client.open(method="OPTIONS", path="/", + headers={"Origin": "http://example.tld"}) self.assertEqual(rv.status_code, 200) for hdr in ("Origin", "Headers", "Credentials", "Methods"): self.assertIn("Access-Control-Allow-%s" % hdr, rv.headers) - self.assertEqual(rv.headers["Access-Control-Allow-Origin"], "http://example.tld") + self.assertEqual( + rv.headers["Access-Control-Allow-Origin"], "http://example.tld") diff --git a/isso/tests/test_db.py b/isso/tests/test_db.py index 3bfd498..e3e7926 100644 --- a/isso/tests/test_db.py +++ b/isso/tests/test_db.py @@ -1,10 +1,6 @@ # -*- encoding: utf-8 -*- -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest import os import sqlite3 import tempfile @@ -65,14 +61,15 @@ class TestDBMigration(unittest.TestCase): "supersecretkey") def test_limit_nested_comments(self): + """Transform previously A -> B -> C comment nesting to A -> B, A -> C""" tree = { 1: None, 2: None, - 3: 2, - 4: 3, - 7: 3, - 5: 2, + 3: 2, + 4: 3, + 7: 3, + 5: 2, 6: None } @@ -94,10 +91,11 @@ class TestDBMigration(unittest.TestCase): " dislikes INTEGER DEFAULT 0," " voters BLOB)") - con.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", ("/", "Test")) + con.execute( + "INSERT INTO threads (uri, title) VALUES (?, ?)", ("/", "Test")) for (id, parent) in iteritems(tree): con.execute("INSERT INTO comments (" - " tid, parent, created)" + " id, parent, created)" "VALUEs (?, ?, ?)", (id, parent, id)) conf = config.new({ @@ -108,16 +106,17 @@ class TestDBMigration(unittest.TestCase): }) SQLite3(self.path, conf) - flattened = [ - (1, None), - (2, None), - (3, 2), - (4, 2), - (5, 2), - (6, None), - (7, 2) - ] + flattened = list(iteritems({ + 1: None, + 2: None, + 3: 2, + 4: 2, + 5: 2, + 6: None, + 7: 2 + })) with sqlite3.connect(self.path) as con: - rv = con.execute("SELECT id, parent FROM comments ORDER BY created").fetchall() + rv = con.execute( + "SELECT id, parent FROM comments ORDER BY created").fetchall() self.assertEqual(flattened, rv) diff --git a/isso/tests/test_guard.py b/isso/tests/test_guard.py index fca932c..4bbb50e 100644 --- a/isso/tests/test_guard.py +++ b/isso/tests/test_guard.py @@ -2,11 +2,7 @@ from __future__ import unicode_literals -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest import os import json import tempfile @@ -72,7 +68,8 @@ class TestGuard(unittest.TestCase): alice = self.makeClient("1.2.3.4", 2) for i in range(2): - self.assertEqual(alice.post("/new?uri=test", data=self.data).status_code, 201) + self.assertEqual(alice.post( + "/new?uri=test", data=self.data).status_code, 201) bob.application.db.execute([ "UPDATE comments SET", @@ -80,7 +77,8 @@ class TestGuard(unittest.TestCase): "WHERE remote_addr = '127.0.0.0'" ]) - self.assertEqual(bob.post("/new?uri=test", data=self.data).status_code, 201) + self.assertEqual( + bob.post("/new?uri=test", data=self.data).status_code, 201) def testDirectReply(self): @@ -99,11 +97,13 @@ class TestGuard(unittest.TestCase): def testSelfReply(self): - payload = lambda id: json.dumps({"text": "...", "parent": id}) + def payload(id): return json.dumps({"text": "...", "parent": id}) client = self.makeClient("127.0.0.1", self_reply=False) - self.assertEqual(client.post("/new?uri=test", data=self.data).status_code, 201) - self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 403) + self.assertEqual(client.post( + "/new?uri=test", data=self.data).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload(1)).status_code, 403) client.application.db.execute([ "UPDATE comments SET", @@ -111,39 +111,55 @@ class TestGuard(unittest.TestCase): "WHERE id = 1" ], (client.application.conf.getint("general", "max-age"), )) - self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload(1)).status_code, 201) client = self.makeClient("128.0.0.1", ratelimit=3, self_reply=False) - self.assertEqual(client.post("/new?uri=test", data=self.data).status_code, 201) - self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 201) - self.assertEqual(client.post("/new?uri=test", data=payload(2)).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=self.data).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload(1)).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload(2)).status_code, 201) def testRequireEmail(self): - payload = lambda email: json.dumps({"text": "...", "email": email}) + def payload(email): return json.dumps({"text": "...", "email": email}) client = self.makeClient("127.0.0.1", ratelimit=4, require_email=False) - client_strict = self.makeClient("127.0.0.2", ratelimit=4, require_email=True) + client_strict = self.makeClient( + "127.0.0.2", ratelimit=4, require_email=True) # if we don't require email - self.assertEqual(client.post("/new?uri=test", data=payload("")).status_code, 201) - self.assertEqual(client.post("/new?uri=test", data=payload("test@me.more")).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload("")).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload("test@me.more")).status_code, 201) # if we do require email - self.assertEqual(client_strict.post("/new?uri=test", data=payload("")).status_code, 403) - self.assertEqual(client_strict.post("/new?uri=test", data=payload("test@me.more")).status_code, 201) + self.assertEqual(client_strict.post( + "/new?uri=test", data=payload("")).status_code, 403) + self.assertEqual(client_strict.post( + "/new?uri=test", data=payload("test@me.more")).status_code, 201) def testRequireAuthor(self): - payload = lambda author: json.dumps({"text": "...", "author": author}) + def payload(author): return json.dumps( + {"text": "...", "author": author}) - client = self.makeClient("127.0.0.1", ratelimit=4, require_author=False) - client_strict = self.makeClient("127.0.0.2", ratelimit=4, require_author=True) + client = self.makeClient( + "127.0.0.1", ratelimit=4, require_author=False) + client_strict = self.makeClient( + "127.0.0.2", ratelimit=4, require_author=True) # if we don't require author - self.assertEqual(client.post("/new?uri=test", data=payload("")).status_code, 201) - self.assertEqual(client.post("/new?uri=test", data=payload("pipo author")).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload("")).status_code, 201) + self.assertEqual(client.post( + "/new?uri=test", data=payload("pipo author")).status_code, 201) # if we do require author - self.assertEqual(client_strict.post("/new?uri=test", data=payload("")).status_code, 403) - self.assertEqual(client_strict.post("/new?uri=test", data=payload("pipo author")).status_code, 201) + self.assertEqual(client_strict.post( + "/new?uri=test", data=payload("")).status_code, 403) + self.assertEqual(client_strict.post( + "/new?uri=test", data=payload("pipo author")).status_code, 201) diff --git a/isso/tests/test_html.py b/isso/tests/test_html.py index b1be6dc..42baa82 100644 --- a/isso/tests/test_html.py +++ b/isso/tests/test_html.py @@ -1,10 +1,6 @@ # -*- encoding: utf-8 -*- -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest import textwrap from isso import config @@ -68,13 +64,18 @@ class TestHTML(unittest.TestCase): sanitizer = html.Sanitizer(elements=[], attributes=[]) examples = [ ('Look: ', 'Look: '), - ('Ha', '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): @@ -95,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/tests/test_migration.py b/isso/tests/test_migration.py index 7b73d75..151f27b 100644 --- a/isso/tests/test_migration.py +++ b/isso/tests/test_migration.py @@ -2,11 +2,7 @@ from __future__ import unicode_literals -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest import tempfile from os.path import join, dirname @@ -33,7 +29,8 @@ class TestMigration(unittest.TestCase): db = SQLite3(xxx.name, conf) Disqus(db, xml).migrate() - self.assertEqual(len(db.execute("SELECT id FROM comments").fetchall()), 2) + self.assertEqual( + len(db.execute("SELECT id FROM comments").fetchall()), 2) self.assertEqual(db.threads["/"]["title"], "Hello, World!") self.assertEqual(db.threads["/"]["id"], 1) @@ -61,8 +58,10 @@ class TestMigration(unittest.TestCase): self.assertEqual(db.threads["/?p=4"]["title"], "...") self.assertEqual(db.threads["/?p=4"]["id"], 2) - self.assertEqual(len(db.execute("SELECT id FROM threads").fetchall()), 2) - self.assertEqual(len(db.execute("SELECT id FROM comments").fetchall()), 7) + self.assertEqual( + len(db.execute("SELECT id FROM threads").fetchall()), 2) + self.assertEqual( + len(db.execute("SELECT id FROM comments").fetchall()), 7) first = db.comments.get(1) self.assertEqual(first["author"], "Ohai") diff --git a/isso/tests/test_utils.py b/isso/tests/test_utils.py index 88668f4..253328c 100644 --- a/isso/tests/test_utils.py +++ b/isso/tests/test_utils.py @@ -1,10 +1,6 @@ # -*- encoding: utf-8 -*- -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest from isso import utils from isso.utils import parse @@ -16,7 +12,8 @@ class TestUtils(unittest.TestCase): examples = [ (u'12.34.56.78', u'12.34.56.0'), - (u'1234:5678:90ab:cdef:fedc:ba09:8765:4321', u'1234:5678:90ab:0000:0000:0000:0000:0000'), + (u'1234:5678:90ab:cdef:fedc:ba09:8765:4321', + u'1234:5678:90ab:0000:0000:0000:0000:0000'), (u'::ffff:127.0.0.1', u'127.0.0.0')] for (addr, anonymized) in examples: @@ -60,4 +57,4 @@ class TestParse(unittest.TestCase): """), ('test', 'Test')) self.assertEqual(parse.thread('
'), - ('Fuu.', 'Untitled.')) + ('Fuu.', 'Untitled.')) diff --git a/isso/tests/test_utils_hash.py b/isso/tests/test_utils_hash.py index 626d75e..5d5160d 100644 --- a/isso/tests/test_utils_hash.py +++ b/isso/tests/test_utils_hash.py @@ -2,10 +2,7 @@ from __future__ import unicode_literals -try: - import unittest2 as unittest -except ImportError: - import unittest +import unittest from isso import config @@ -36,7 +33,8 @@ class TestHasher(unittest.TestCase): class TestPBKDF2(unittest.TestCase): def test_default(self): - pbkdf2 = PBKDF2(iterations=1000) # original setting (and still default) + # original setting (and still default) + pbkdf2 = PBKDF2(iterations=1000) self.assertEqual(pbkdf2.uhash(""), "42476aafe2e4") def test_different_salt(self): @@ -70,4 +68,4 @@ class TestCreate(unittest.TestCase): pbkdf2 = _new("pbkdf2:16:2:md5") self.assertIsInstance(pbkdf2, PBKDF2) self.assertEqual(pbkdf2.dklen, 2) - self.assertEqual(pbkdf2.func, "md5") \ No newline at end of file + self.assertEqual(pbkdf2.func, "md5") diff --git a/isso/tests/test_vote.py b/isso/tests/test_vote.py index f6817a0..3e3b1b8 100644 --- a/isso/tests/test_vote.py +++ b/isso/tests/test_vote.py @@ -4,11 +4,7 @@ from __future__ import unicode_literals import os import json import tempfile - -try: - import unittest2 as unittest -except ImportError: - import unittest +import unittest from werkzeug.wrappers import Response @@ -41,13 +37,15 @@ class TestVote(unittest.TestCase): def testZeroLikes(self): - rv = self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + rv = self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."})) self.assertEqual(loads(rv.data)['likes'], 0) self.assertEqual(loads(rv.data)['dislikes'], 0) def testSingleLike(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."})) rv = self.makeClient("0.0.0.0").post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -64,7 +62,8 @@ class TestVote(unittest.TestCase): def testMultipleLikes(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."})) for num in range(15): rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') self.assertEqual(rv.status_code, 200) @@ -77,7 +76,8 @@ class TestVote(unittest.TestCase): def testTooManyLikes(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."})) for num in range(256): rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') self.assertEqual(rv.status_code, 200) @@ -88,7 +88,8 @@ class TestVote(unittest.TestCase): self.assertEqual(loads(rv.data)["likes"], num + 1) def testDislike(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."})) rv = self.makeClient("1.2.3.4").post('/id/1/dislike') self.assertEqual(rv.status_code, 200) diff --git a/isso/tests/test_wsgi.py b/isso/tests/test_wsgi.py index 17e0b12..254b51e 100644 --- a/isso/tests/test_wsgi.py +++ b/isso/tests/test_wsgi.py @@ -1,10 +1,6 @@ # -*- encoding: utf-8 -*- -try: - import unittest2 as unittest -except ImportError: - import unittest - +import unittest from isso import wsgi diff --git a/isso/utils/__init__.py b/isso/utils/__init__.py index a7cafd5..254aab1 100644 --- a/isso/utils/__init__.py +++ b/isso/utils/__init__.py @@ -5,9 +5,12 @@ from __future__ import division, unicode_literals import pkg_resources werkzeug = pkg_resources.get_distribution("werkzeug") -import json import hashlib +import json +import os +from datetime import datetime +from jinja2 import Environment, FileSystemLoader from werkzeug.wrappers import Response from werkzeug.exceptions import BadRequest @@ -86,11 +89,11 @@ class Bloomfilter: def add(self, key): for i in self.get_probes(key): - self.array[i//8] |= 2 ** (i%8) + self.array[i//8] |= 2 ** (i % 8) self.elements += 1 def __contains__(self, key): - return all(self.array[i//8] & (2 ** (i%8)) for i in self.get_probes(key)) + return all(self.array[i//8] & (2 ** (i % 8)) for i in self.get_probes(key)) def __len__(self): return self.elements @@ -109,9 +112,30 @@ class JSONRequest(Request): raise BadRequest('Unable to read JSON request') +def render_template(template_name, **context): + template_path = os.path.join(os.path.dirname(__file__), + '..', 'templates') + jinja_env = Environment(loader=FileSystemLoader(template_path), + autoescape=True) + + def datetimeformat(value): + return datetime.fromtimestamp(value).strftime('%H:%M / %d-%m-%Y') + + jinja_env.filters['datetimeformat'] = datetimeformat + t = jinja_env.get_template(template_name) + return Response(t.render(context), mimetype='text/html') + + class JSONResponse(Response): def __init__(self, obj, *args, **kwargs): kwargs["content_type"] = "application/json" super(JSONResponse, self).__init__( json.dumps(obj).encode("utf-8"), *args, **kwargs) + + +class XMLResponse(Response): + def __init__(self, obj, *args, **kwargs): + kwargs["content_type"] = "text/xml" + super(XMLResponse, self).__init__( + obj, *args, **kwargs) diff --git a/isso/utils/hash.py b/isso/utils/hash.py index 2ade078..d964036 100644 --- a/isso/utils/hash.py +++ b/isso/utils/hash.py @@ -73,7 +73,7 @@ class Hash(object): class PBKDF2(Hash): - + def __init__(self, salt=None, iterations=1000, dklen=6, func="sha1"): super(PBKDF2, self).__init__(salt) @@ -110,4 +110,4 @@ def new(conf): sha1 = Hash(func="sha1").uhash -md5 = Hash(func="md5").uhash +md5 = Hash(func="md5").uhash \ No newline at end of file diff --git a/isso/utils/html.py b/isso/utils/html.py index 294b8d4..1f5f8cd 100644 --- a/isso/utils/html.py +++ b/isso/utils/html.py @@ -50,11 +50,17 @@ def sanitize(tokenizer, document): if HTML5LIB_VERSION > HTML5LIB_SIMPLETREE: builder = "etree" + + for link in domtree.findall(".//{http://www.w3.org/1999/xhtml}a"): + if link.get('href', None): + link.set("rel", "nofollow noopener") + else: builder = "simpletree" stream = html5lib.treewalkers.getTreeWalker(builder)(domtree) - serializer = HTMLSerializer(quote_attr_values=True, omit_optional_tags=False) + serializer = HTMLSerializer( + quote_attr_values=True, omit_optional_tags=False) return serializer.render(stream) diff --git a/isso/utils/http.py b/isso/utils/http.py index 48b968b..4bbf443 100644 --- a/isso/utils/http.py +++ b/isso/utils/http.py @@ -44,7 +44,7 @@ class curl(object): self.con = http(host, port, timeout=self.timeout) try: self.con.request(self.method, self.path, headers=self.headers) - except (httplib.HTTPException, socket.error) as e: + except (httplib.HTTPException, socket.error): return None try: diff --git a/isso/utils/parse.py b/isso/utils/parse.py index 85c931b..d15b15f 100644 --- a/isso/utils/parse.py +++ b/isso/utils/parse.py @@ -15,7 +15,7 @@ from isso.compat import map, filter, PY2K if PY2K: # http://bugs.python.org/issue12984 from xml.dom.minidom import NamedNodeMap - NamedNodeMap.__contains__ = lambda self, key: self.has_key(key) + NamedNodeMap.__contains__ = lambda self, key: self.has_key(key) # noqa def thread(data, default=u"Untitled.", id=None): @@ -31,8 +31,8 @@ def thread(data, default=u"Untitled.", id=None): # aka getElementById, but limited to div and section tags el = list(filter(lambda i: i.attributes["id"].value == "isso-thread", - filter(lambda i: "id" in i.attributes, - chain(*map(html.getElementsByTagName, ("div", "section")))))) + filter(lambda i: "id" in i.attributes, + chain(*map(html.getElementsByTagName, ("div", "section")))))) if not el: return id, default diff --git a/isso/views/__init__.py b/isso/views/__init__.py index 0b995cd..54e1619 100644 --- a/isso/views/__init__.py +++ b/isso/views/__init__.py @@ -40,7 +40,8 @@ class requires: try: kwargs[self.param] = self.type(req.args[self.param]) except TypeError: - raise BadRequest("invalid type for %s, expected %s" % (self.param, self.type)) + raise BadRequest("invalid type for %s, expected %s" % + (self.param, self.type)) return func(cls, env, req, *args, **kwargs) diff --git a/isso/views/comments.py b/isso/views/comments.py index 1be4831..a6f39fc 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -7,7 +7,9 @@ import cgi import time import functools +from datetime import datetime, timedelta from itsdangerous import SignatureExpired, BadSignature +from xml.etree import ElementTree as ET from werkzeug.http import dump_cookie from werkzeug.wsgi import get_current_url @@ -19,16 +21,28 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound from isso.compat import text_type as str from isso import utils, local -from isso.utils import http, parse, JSONResponse as JSON +from isso.utils import (http, parse, + JSONResponse as JSON, XMLResponse as XML, + render_template) from isso.views import requires from isso.utils.hash import sha1 from isso.utils.hash import md5 +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse +try: + from StringIO import StringIO +except ImportError: + from io import BytesIO as StringIO + # from Django appearently, looks good to me *duck* __url_re = re.compile( r'^' r'(https?://)?' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... + # domain... + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' r'localhost|' # localhost... r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip r'(?::\d+)?' # optional port @@ -61,6 +75,12 @@ def xhr(func): not forged (XHR is restricted by CORS separately). """ + """ + @apiDefine csrf + @apiHeader {string="application/json"} Content-Type + The content type must be set to `application/json` to prevent CSRF attacks. + """ + def dec(self, env, req, *args, **kwargs): if req.content_type and not req.content_type.startswith("application/json"): @@ -83,15 +103,18 @@ class API(object): ('new', ('POST', '/new')), ('count', ('GET', '/count')), ('counts', ('POST', '/count')), + ('feed', ('GET', '/feed')), ('view', ('GET', '/id/')), ('edit', ('PUT', '/id/')), ('delete', ('DELETE', '/id/')), - ('moderate',('GET', '/id///')), - ('moderate',('POST', '/id///')), + ('moderate', ('GET', '/id///')), + ('moderate', ('POST', '/id///')), ('like', ('POST', '/id//like')), ('dislike', ('POST', '/id//dislike')), ('demo', ('GET', '/demo')), - ('preview', ('POST', '/preview')) + ('preview', ('POST', '/preview')), + ('login', ('POST', '/login')), + ('admin', ('GET', '/admin')) ] def __init__(self, isso, hasher): @@ -142,6 +165,78 @@ class API(object): return True, "" + # Common definitions for apidoc follow: + """ + @apiDefine plainParam + @apiParam {number=0,1} [plain] + Iff set to `1`, the plain text entered by the user will be returned in the comments’ `text` attribute (instead of the rendered markdown). + """ + """ + @apiDefine commentResponse + + @apiSuccess {number} id + The comment’s id (assigned by the server). + @apiSuccess {number} parent + Id of the comment this comment is a reply to. `null` if this is a top-level-comment. + @apiSuccess {number=1,2,4} mode + The comment’s mode: + value | explanation + --- | --- + `1` | accepted: The comment was accepted by the server and is published. + `2` | in moderation queue: The comment was accepted by the server but awaits moderation. + `4` | deleted, but referenced: The comment was deleted on the server but is still referenced by replies. + @apiSuccess {string} author + The comments’s author’s name or `null`. + @apiSuccess {string} website + The comment’s author’s website or `null`. + @apiSuccess {string} hash + A hash uniquely identifying the comment’s author. + @apiSuccess {number} created + UNIX timestamp of the time the comment was created (on the server). + @apiSuccess {number} modified + UNIX timestamp of the time the comment was last modified (on the server). `null` if the comment was not yet modified. + """ + + """ + @api {post} /new create new + @apiGroup Comment + @apiDescription + Creates a new comment. The response will set a cookie on the requestor to enable them to later edit the comment. + @apiUse csrf + + @apiParam {string} uri + The uri of the thread to create the comment on. + @apiParam {string} text + The comment’s raw text. + @apiParam {string} [author] + The comment’s author’s name. + @apiParam {string} [email] + The comment’s author’s email address. + @apiParam {string} [website] + The comment’s author’s website’s url. + @apiParam {number} [parent] + The parent comment’s id iff the new comment is a response to an existing comment. + + @apiExample {curl} Create a reply to comment with id 15: + curl 'https://comments.example.com/new?uri=/thread/' -d '{"text": "Stop saying that! *isso*!", "author": "Max Rant", "email": "rant@example.com", "parent": 15}' -H 'Content-Type: application/json' -c cookie.txt + + @apiUse commentResponse + + @apiSuccessExample Success after the above request: + { + "website": null, + "author": "Max Rant", + "parent": 15, + "created": 1464940838.254393, + "text": "<p>Stop saying that! <em>isso</em>!</p>", + "dislikes": 0, + "modified": null, + "mode": 1, + "hash": "e644f6ee43c0", + "id": 23, + "likes": 0 + } + """ @xhr @requires(str, 'uri') def new(self, environ, request, uri): @@ -199,13 +294,15 @@ class API(object): self.signal("comments.new:after-save", thread, rv) cookie = functools.partial(dump_cookie, - value=self.isso.sign([rv["id"], sha1(rv["text"])]), - max_age=self.conf.getint('max-age')) + value=self.isso.sign( + [rv["id"], sha1(rv["text"])]), + max_age=self.conf.getint('max-age')) rv["text"] = self.isso.render(rv["text"]) rv["hash"] = self.hash(rv['email'] or rv['remote_addr']) - self.cache.set('hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash']) + self.cache.set( + 'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash']) rv = self._add_gravatar_image(rv) @@ -220,6 +317,34 @@ class API(object): resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"])) return resp + """ + @api {get} /id/:id view + @apiGroup Comment + + @apiParam {number} id + The id of the comment to view. + @apiUse plainParam + + @apiExample {curl} View the comment with id 4: + curl 'https://comments.example.com/id/4' + + @apiUse commentResponse + + @apiSuccessExample Example result: + { + "website": null, + "author": null, + "parent": null, + "created": 1464914341.312426, + "text": " <p>I want to use MySQL</p>", + "dislikes": 0, + "modified": null, + "mode": 1, + "id": 4, + "likes": 1 + } + """ + def view(self, environ, request, id): rv = self.comments.get(id) @@ -234,6 +359,41 @@ class API(object): return JSON(rv, 200) + """ + @api {put} /id/:id edit + @apiGroup Comment + @apiDescription + Edit an existing comment. Editing a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details. Editing a comment will set a new edit cookie in the response. + @apiUse csrf + + @apiParam {number} id + The id of the comment to edit. + @apiParam {string} text + A new (raw) text for the comment. + @apiParam {string} [author] + The modified comment’s author’s name. + @apiParam {string} [webiste] + The modified comment’s author’s website. + + @apiExample {curl} Edit comment with id 23: + curl -X PUT 'https://comments.example.com/id/23' -d {"text": "I see your point. However, I still disagree.", "website": "maxrant.important.com"} -H 'Content-Type: application/json' -b cookie.txt + + @apiUse commentResponse + + @apiSuccessExample Example response: + { + "website": "maxrant.important.com", + "author": "Max Rant", + "parent": 15, + "created": 1464940838.254393, + "text": "<p>I see your point. However, I still disagree.</p>", + "dislikes": 0, + "modified": 1464943439.073961, + "mode": 1, + "id": 23, + "likes": 0 + } + """ @xhr def edit(self, environ, request, id): @@ -268,8 +428,9 @@ class API(object): self.signal("comments.edit", rv) cookie = functools.partial(dump_cookie, - value=self.isso.sign([rv["id"], sha1(rv["text"])]), - max_age=self.conf.getint('max-age')) + value=self.isso.sign( + [rv["id"], sha1(rv["text"])]), + max_age=self.conf.getint('max-age')) rv["text"] = self.isso.render(rv["text"]) @@ -278,6 +439,21 @@ class API(object): resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"])) return resp + """ + @api {delete} '/id/:id' delete + @apiGroup Comment + @apiDescription + Delte an existing comment. Deleting a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details. + + @apiParam {number} id + Id of the comment to delete. + + @apiExample {curl} Delete comment with id 14: + curl -X DELETE 'https://comments.example.com/id/14' -b cookie.txt + + @apiSuccessExample Successful deletion returns null: + null + """ @xhr def delete(self, environ, request, id, key=None): @@ -298,7 +474,8 @@ class API(object): if item is None: raise NotFound - self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8')) + self.cache.delete( + 'hash', (item['email'] or item['remote_addr']).encode('utf-8')) with self.isso.lock: rv = self.comments.delete(id) @@ -315,8 +492,42 @@ class API(object): resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id)) return resp - def moderate(self, environ, request, id, action, key): + """ + @api {post} /id/:id/:action/key moderate + @apiGroup Comment + @apiDescription + Publish or delete a comment that is in the moderation queue (mode `2`). In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso. + This endpoint can also be used with a `GET` request. In that case, a html page is returned that asks the user whether they are sure to perform the selected action. If they select “yes”, the query is repeated using `POST`. + + @apiParam {number} id + The id of the comment to moderate. + @apiParam {string=activate,delete} action + `activate` to publish the comment (change its mode to `1`). + `delete` to delete the comment + @apiParam {string} key + The moderation key to authenticate the moderation. + + @apiExample {curl} delete comment with id 13: + curl -X POST 'https://comments.example.com/id/13/delete/MTM.CjL6Fg.REIdVXa-whJS_x8ojQL4RrXnuF4' + + @apiSuccessExample {html} Using GET: + <!DOCTYPE html> + <html> + <head> + <script> + if (confirm('Delete: Are you sure?')) { + xhr = new XMLHttpRequest; + xhr.open('POST', window.location.href); + xhr.send(null); + } + </script> + + @apiSuccessExample Using POST: + Yo + """ + + def moderate(self, environ, request, id, action, key): try: id = self.isso.unsign(key, max_age=2**32) except (BadSignature, SignatureExpired): @@ -346,14 +557,105 @@ class API(object): with self.isso.lock: self.comments.activate(id) self.signal("comments.activate", id) + return Response("Yo", 200) + elif action == "edit": + data = request.get_json() + with self.isso.lock: + rv = self.comments.update(id, data) + for key in set(rv.keys()) - API.FIELDS: + rv.pop(key) + self.signal("comments.edit", rv) + return JSON(rv, 200) else: with self.isso.lock: self.comments.delete(id) - self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8')) + self.cache.delete( + 'hash', (item['email'] or item['remote_addr']).encode('utf-8')) self.signal("comments.delete", id) + return Response("Yo", 200) - return Response("Yo", 200) + """ + @api {get} / get comments + @apiGroup Thread + @apiDescription Queries the comments of a thread. + @apiParam {string} uri + The URI of thread to get the comments from. + @apiParam {number} [parent] + Return only comments that are children of the comment with the provided ID. + @apiUse plainParam + @apiParam {number} [limit] + The maximum number of returned top-level comments. Omit for unlimited results. + @apiParam {number} [nested_limit] + The maximum number of returned nested comments per commint. Omit for unlimited results. + @apiParam {number} [after] + Includes only comments were added after the provided UNIX timestamp. + + @apiSuccess {number} total_replies + The number of replies if the `limit` parameter was not set. If `after` is set to `X`, this is the number of comments that were created after `X`. So setting `after` may change this value! + @apiSuccess {Object[]} replies + The list of comments. Each comment also has the `total_replies`, `replies`, `id` and `hidden_replies` properties to represent nested comments. + @apiSuccess {number} id + Id of the comment `replies` is the list of replies of. `null` for the list of toplevel comments. + @apiSuccess {number} hidden_replies + The number of comments that were ommited from the results because of the `limit` request parameter. Usually, this will be `total_replies` - `limit`. + + @apiExample {curl} Get 2 comments with 5 responses: + curl 'https://comments.example.com/?uri=/thread/&limit=2&nested_limit=5' + @apiSuccessExample Example reponse: + { + "total_replies": 14, + "replies": [ + { + "website": null, + "author": null, + "parent": null, + "created": 1464818460.732863, + "text": "<p>Hello, World!</p>", + "total_replies": 1, + "hidden_replies": 0, + "dislikes": 2, + "modified": null, + "mode": 1, + "replies": [ + { + "website": null, + "author": null, + "parent": 1, + "created": 1464818460.769638, + "text": "<p>Hi, now some Markdown: <em>Italic</em>, <strong>bold</strong>, <code>monospace</code>.</p>", + "dislikes": 0, + "modified": null, + "mode": 1, + "hash": "2af4e1a6c96a", + "id": 2, + "likes": 2 + } + ], + "hash": "1cb6cc0309a2", + "id": 1, + "likes": 2 + }, + { + "website": null, + "author": null, + "parent": null, + "created": 1464818460.80574, + "text": "<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium at commodi cum deserunt dolore, error fugiat harum incidunt, ipsa ipsum mollitia nam provident rerum sapiente suscipit tempora vitae? Est, qui?</p>", + "total_replies": 0, + "hidden_replies": 0, + "dislikes": 0, + "modified": null, + "mode": 1, + "replies": [], + "hash": "1cb6cc0309a2", + "id": 3, + "likes": 0 + }, + "id": null, + "hidden_replies": 12 + } + """ @requires(str, 'uri') def fetch(self, environ, request, uri): @@ -401,10 +703,10 @@ class API(object): return BadRequest("nested_limit should be integer") rv = { - 'id' : root_id, - 'total_replies' : reply_counts[root_id], - 'hidden_replies' : reply_counts[root_id] - len(root_list), - 'replies' : self._process_fetched_list(root_list, plain) + 'id': root_id, + 'total_replies': reply_counts[root_id], + 'hidden_replies': reply_counts[root_id] - len(root_list), + 'replies': self._process_fetched_list(root_list, plain) } # We are only checking for one level deep comments if root_id is None: @@ -425,7 +727,8 @@ class API(object): comment['total_replies'] = 0 replies = [] - comment['hidden_replies'] = comment['total_replies'] - len(replies) + comment['hidden_replies'] = comment['total_replies'] - \ + len(replies) comment['replies'] = self._process_fetched_list(replies, plain) return JSON(rv, 200) @@ -463,16 +766,66 @@ class API(object): return fetched_list + """ + @apiDefine likeResponse + @apiSuccess {number} likes + The (new) number of likes on the comment. + @apiSuccess {number} dislikes + The (new) number of dislikes on the comment. + """ + + """ + @api {post} /id/:id/like like + @apiGroup Comment + @apiDescription + Puts a “like” on a comment. The author of a comment cannot like its own comment. + + @apiParam {number} id + The id of the comment to like. + + @apiExample {curl} Like comment with id 23: + curl -X POST 'https://comments.example.com/id/23/like' + + @apiUse likeResponse + + @apiSuccessExample Example response + { + "likes": 5, + "dislikes": 2 + } + """ @xhr def like(self, environ, request, id): - nv = self.comments.vote(True, id, utils.anonymize(str(request.remote_addr))) + nv = self.comments.vote( + True, id, utils.anonymize(str(request.remote_addr))) return JSON(nv, 200) + """ + @api {post} /id/:id/dislike dislike + @apiGroup Comment + @apiDescription + Puts a “dislike” on a comment. The author of a comment cannot dislike its own comment. + + @apiParam {number} id + The id of the comment to dislike. + + @apiExample {curl} Dislike comment with id 23: + curl -X POST 'https://comments.example.com/id/23/dislike' + + @apiUse likeResponse + + @apiSuccessExample Example response + { + "likes": 4, + "dislikes": 3 + } + """ @xhr def dislike(self, environ, request, id): - nv = self.comments.vote(False, id, utils.anonymize(str(request.remote_addr))) + nv = self.comments.vote( + False, id, utils.anonymize(str(request.remote_addr))) return JSON(nv, 200) # TODO: remove someday (replaced by :func:`counts`) @@ -486,6 +839,19 @@ class API(object): return JSON(rv, 200) + """ + @api {post} /count count comments + @apiGroup Thread + @apiDescription + Counts the number of comments on multiple threads. The requestor provides a list of thread uris. The number of comments on each thread is returned as a list, in the same order as the threads were requested. The counts include comments that are reponses to comments. + + @apiExample {curl} get the count of 5 threads: + curl 'https://comments.example.com/count' -d '["/blog/firstPost.html", "/blog/controversalPost.html", "/blog/howToCode.html", "/blog/boringPost.html", "/blog/isso.html"] + + @apiSuccessExample Counts of 5 threads: + [2, 18, 4, 0, 3] + """ + def counts(self, environ, request): data = request.get_json() @@ -495,6 +861,125 @@ class API(object): return JSON(self.comments.count(*data), 200) + """ + @api {get} /feed Atom feed for comments + @apiGroup Thread + @apiDescription + Provide an Atom feed for the given thread. + """ + @requires(str, 'uri') + def feed(self, environ, request, uri): + conf = self.isso.conf.section("rss") + if not conf.get('base'): + raise NotFound + + args = { + 'uri': uri, + 'order_by': 'id', + 'asc': 0, + 'limit': conf.getint('limit') + } + try: + args['limit'] = max(int(request.args.get('limit')), args['limit']) + except TypeError: + pass + except ValueError: + return BadRequest("limit should be integer") + comments = self.comments.fetch(**args) + base = conf.get('base') + hostname = urlparse(base).netloc + + # Let's build an Atom feed. + # RFC 4287: https://tools.ietf.org/html/rfc4287 + # RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions) + # For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id + feed = ET.Element('feed', { + 'xmlns': 'http://www.w3.org/2005/Atom', + 'xmlns:thr': 'http://purl.org/syndication/thread/1.0' + }) + + # For feed ID, we would use thread ID, but we may not have + # one. Therefore, we use the URI. We don't have a year + # either... + id = ET.SubElement(feed, 'id') + id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format( + hostname=hostname, uri=uri) + + # For title, we don't have much either. Be pretty generic. + title = ET.SubElement(feed, 'title') + title.text = 'Comments for {hostname}{uri}'.format( + hostname=hostname, uri=uri) + + comment0 = None + + for comment in comments: + if comment0 is None: + comment0 = comment + + entry = ET.SubElement(feed, 'entry') + # We don't use a real date in ID either to help with + # threading. + id = ET.SubElement(entry, 'id') + id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format( + hostname=hostname, + tid=comment['tid'], + id=comment['id']) + title = ET.SubElement(entry, 'title') + title.text = 'Comment #{}'.format(comment['id']) + updated = ET.SubElement(entry, 'updated') + updated.text = '{}Z'.format(datetime.fromtimestamp( + comment['modified'] or comment['created']).isoformat()) + author = ET.SubElement(entry, 'author') + name = ET.SubElement(author, 'name') + name.text = comment['author'] + ET.SubElement(entry, 'link', { + 'href': '{base}{uri}#isso-{id}'.format( + base=base, + uri=uri, id=comment['id']) + }) + content = ET.SubElement(entry, 'content', { + 'type': 'html', + }) + content.text = self.isso.render(comment['text']) + + if comment['parent']: + ET.SubElement(entry, 'thr:in-reply-to', { + 'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format( + hostname=hostname, + tid=comment['tid'], + id=comment['parent']), + 'href': '{base}{uri}#isso-{id}'.format( + base=base, + uri=uri, id=comment['parent']) + }) + + # Updated is mandatory. If we have comments, we use the date + # of last modification of the first one (which is the last + # one). Otherwise, we use a fixed date. + updated = ET.Element('updated') + if comment0 is None: + updated.text = '1970-01-01T01:00:00Z' + else: + updated.text = datetime.fromtimestamp( + comment0['modified'] or comment0['created']).isoformat() + updated.text += 'Z' + feed.insert(0, updated) + + output = StringIO() + ET.ElementTree(feed).write(output, + encoding='utf-8', + xml_declaration=True) + response = XML(output.getvalue(), 200) + + # Add an etag/last-modified value for caching purpose + if comment0 is None: + response.set_etag('empty') + response.last_modified = 0 + else: + response.set_etag('{tid}-{id}'.format(**comment0)) + response.last_modified = comment0['modified'] or comment0['created'] + return response.make_conditional(request) + def preview(self, environment, request): data = request.get_json() @@ -504,4 +989,53 @@ class API(object): return JSON({'text': self.isso.render(data["text"])}, 200) def demo(self, env, req): - return redirect(get_current_url(env) + '/index.html') + return redirect( + get_current_url(env, strip_querystring=True) + '/index.html' + ) + + def login(self, env, req): + data = req.form + password = self.isso.conf.get("general", "admin_password") + if data['password'] and data['password'] == password: + response = redirect(re.sub( + r'/login$', + '/admin', + get_current_url(env, strip_querystring=True) + )) + cookie = functools.partial(dump_cookie, + value=self.isso.sign({"logged": True}), + expires=datetime.now() + timedelta(1)) + response.headers.add("Set-Cookie", cookie("admin-session")) + response.headers.add("X-Set-Cookie", cookie("isso-admin-session")) + return response + else: + return render_template('login.html') + + def admin(self, env, req): + try: + data = self.isso.unsign(req.cookies.get('admin-session', ''), + max_age=60 * 60 * 24) + except BadSignature: + return render_template('login.html') + if not data or not data['logged']: + return render_template('login.html') + page_size = 100 + page = int(req.args.get('page', 0)) + order_by = req.args.get('order_by', None) + asc = int(req.args.get('asc', 1)) + mode = int(req.args.get('mode', 2)) + comments = self.comments.fetchall(mode=mode, page=page, + limit=page_size, + order_by=order_by, + asc=asc) + comments_enriched = [] + for comment in list(comments): + comment['hash'] = self.isso.sign(comment['id']) + comments_enriched.append(comment) + comment_mode_count = self.comments.count_modes() + max_page = int(sum(comment_mode_count.values()) / 100) + return render_template('admin.html', comments=comments_enriched, + page=int(page), mode=int(mode), + conf=self.conf, max_page=max_page, + counts=comment_mode_count, + order_by=order_by, asc=asc) diff --git a/isso/wsgi.py b/isso/wsgi.py index 1bad0f2..60c1a58 100644 --- a/isso/wsgi.py +++ b/isso/wsgi.py @@ -84,6 +84,8 @@ def origin(hosts): hosts = [urlsplit(h) for h in hosts] def func(environ): + if 'ISSO_CORS_ORIGIN' in environ: + return environ['ISSO_CORS_ORIGIN'] if not hosts: return "http://invalid.local" @@ -136,11 +138,14 @@ class CORSMiddleware(object): headers = Headers(headers) headers.add("Access-Control-Allow-Origin", self.origin(environ)) headers.add("Access-Control-Allow-Credentials", "true") - headers.add("Access-Control-Allow-Methods", ", ".join(self.methods)) + headers.add("Access-Control-Allow-Methods", + ", ".join(self.methods)) if self.allowed: - headers.add("Access-Control-Allow-Headers", ", ".join(self.allowed)) + headers.add("Access-Control-Allow-Headers", + ", ".join(self.allowed)) if self.exposed: - headers.add("Access-Control-Expose-Headers", ", ".join(self.exposed)) + headers.add("Access-Control-Expose-Headers", + ", ".join(self.exposed)) return start_response(status, headers.to_list(), exc_info) if environ.get("REQUEST_METHOD") == "OPTIONS": diff --git a/setup.py b/setup.py index e54c110..ef866fe 100644 --- a/setup.py +++ b/setup.py @@ -5,10 +5,13 @@ import sys from setuptools import setup, find_packages -requires = ['itsdangerous', 'misaka>=1.0,<2.0', 'html5lib==0.9999999'] +requires = ['html5lib==0.9999999', 'itsdangerous', 'Jinja2', + 'misaka>=1.0,<2.0', 'werkzeug>=0.9'] -if (3, 0) <= sys.version_info < (3, 3): - raise SystemExit("Python 3.0, 3.1 and 3.2 are not supported") +if sys.version_info < (2, 7): + raise SystemExit("Python 2 versions < 2.7 are not supported.") +elif (3, 0) <= sys.version_info < (3, 4): + raise SystemExit("Python 3 versions < 3.4 are not supported.") setup( name='isso', @@ -27,16 +30,14 @@ setup( "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4" + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6" ], install_requires=requires, extras_require={ - ':python_version=="2.6"': ['argparse', 'ordereddict'], - ':python_version=="2.6" or python_version=="2.7"': ['ipaddr>=2.1', 'configparser', 'werkzeug>=0.8'], - ':python_version!="2.6" and python_version!="2.7"': ['werkzeug>=0.9'] + ':python_version=="2.7"': ['ipaddr>=2.1', 'configparser'] }, entry_points={ 'console_scripts': diff --git a/share/isso-dev.conf b/share/isso-dev.conf index ce87ab3..970c1b0 100644 --- a/share/isso-dev.conf +++ b/share/isso-dev.conf @@ -10,6 +10,7 @@ host = http://isso-dev.local/ max-age = 15m notify = stdout log-file = /var/log/isso.log +admin_password = strong_default_password_for_isso_admin [moderation] enabled = false diff --git a/share/isso.conf b/share/isso.conf index 4bb3ef3..57a1155 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -10,7 +10,7 @@ dbpath = /tmp/comments.db # required to dispatch multiple websites, not used otherwise. name = -# Your website(s). If Isso is unable to connect to at least on site, you'll +# Your website(s). If Isso is unable to connect to at least one site, you'll # get a warning during startup and comments are most likely non-functional. # # You'll need at least one host/website to run Isso. This is due to security @@ -43,7 +43,7 @@ max-age = 15m # moderated) and deletion links. notify = stdout -# Log console messages to file instead of standard out. +# Log console messages to file instead of standard output. log-file = # adds property "gravatar_image" to json response when true @@ -54,6 +54,10 @@ gravatar = false # default url for gravatar. {} is where the hash will be placed gravatar-url = https://www.gravatar.com/avatar/{}?d=identicon +# Admin access password +admin_password = please_choose_a_strong_password + + [moderation] # enable comment moderation queue. This option only affects new comments. # Comments in modertion queue are not visible to other users until you activate @@ -184,3 +188,15 @@ salt = Eech7co8Ohloopo9Ol6baimi # strengthening. Arguments have to be in that order, but can be reduced to # pbkdf2:4096 for example to override the iterations only. algorithm = pbkdf2 + + +[rss] +# Provide an Atom feed for each comment thread for users to subscribe to. + +# The base URL of pages is needed to build the Atom feed. By appending +# the URI, we should get the complete URL to use to access the page +# with the comments. When empty, Atom feeds are disabled. +base = + +# Limit the number of elements to return for each thread. +limit = 100 diff --git a/tox.ini b/tox.ini index 06d29de..4cc7c2c 100755 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py33,py34,py35 +envlist = py27,py34,py35,py36 [testenv] deps = @@ -8,14 +8,6 @@ deps = commands = python setup.py nosetests -[testenv:py26] -deps = - argparse - unittest2 - ordereddict - configparser - {[testenv]deps} - [testenv:py27] deps = configparser @@ -29,15 +21,3 @@ deps= misaka==1.0.2 passlib==1.5.3 werkzeug==0.8.3 - -[testenv:squeeze] -basepython=python2.6 -deps= - {[testenv:py26]deps} - {[testenv:debian]deps} - -[testenv:wheezy] -basepython=python2.7 -deps = - {[testenv:py27]deps} - {[testenv:debian]deps}