diff --git a/CHANGES.rst b/CHANGES.rst index 541774426..15379f77b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -24,8 +24,11 @@ Changelog for Isso to style comments and replies made by the page's author(s). - Add Ukrainian localisation (`#878`_, okawo80085) - Enable Turkish localisation (`#879`_, okawo80085) -- Add ``/config`` endpoint for fetching server configuration options that - affect the client +- **API:** + + - Add ``/config`` endpoint for fetching server configuration options that + affect the client + - Remove ``/count`` GET endpoint (use POST instead) .. _Gravatar: Image requests: http://en.gravatar.com/site/implement/images/ .. _879: https://github.com/posativ/isso/pull/879 diff --git a/Makefile b/Makefile index 779173415..6d237ed40 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ DOCS_CSS_DST := docs/_static/css/site.css DOCS_HTML_DST := docs/_build/html -APIDOC_SRC := apidoc/apidoc.json apidoc/header.md apidoc/footer.md +APIDOC_SRC := apidoc/apidoc.json apidoc/header.md apidoc/footer.md apidoc/_apidoc.js APIDOC_DST := apidoc/_output @@ -79,7 +79,9 @@ apidoc-init: apidoc: $(ISSO_PY_SRC) $(APIDOC_SRC) $(APIDOC) --config apidoc/apidoc.json \ - --input isso/views/ --output $(APIDOC_DST) + --input isso/views/ --input apidoc/ \ + --output $(APIDOC_DST) + cp -rT $(APIDOC_DST) $(DOCS_HTML_DST)/docs/api/ coverage: $(ISSO_PY_SRC) coverage run --omit='*/tests/*' --source isso -m pytest diff --git a/apidoc/_apidoc.js b/apidoc/_apidoc.js new file mode 100644 index 000000000..8896805d8 --- /dev/null +++ b/apidoc/_apidoc.js @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------------------------ +// History. +// ------------------------------------------------------------------------------------------ +/** +* @api {get} /count (Deprecated) Count for single thread +* @apiGroup Thread +* @apiName count +* @apiVersion 0.12.6 +* @apiDeprecated use (#Thread:counts) instead. +* @apiDescription +* (Deprecated) Counts the number of comments for a single thread. + +* @apiBody {Number[]} urls +* Array of URLs for which to fetch comment counts + +* @apiExample {curl} Get the respective counts 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 {json} Counts of 5 threads: +* [2, 18, 4, 0, 3] +*/ + +/** +* @api {post} /new create new +* @apiGroup Comment +* @apiName new +* @apiVersion 0.12.6 +* @apiDescription +* Creates a new comment. The server issues a cookie per new comment which acts as +* an authentication token to modify or delete the comment. +* The token is cryptographically signed and expires automatically after 900 seconds (=15min) by default. +* @apiUse csrf + +* @apiQuery {String} uri +* The uri of the thread to create the comment on. +* @apiBody {String{3..}} text +* The comment’s raw text. +* @apiBody {String} [author] +* The comment’s author’s name. +* @apiBody {String} [email] +* The comment’s author’s email address. +* @apiBody {String} [website] +* The comment’s author’s website’s url. +* @apiBody {number} [parent] +* The parent comment’s id if 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 {json} Success after the above request: +* HTTP/1.1 201 CREATED +* Set-Cookie: 1=...; Expires=Wed, 18-Dec-2013 12:57:20 GMT; Max-Age=900; Path=/ +* X-Set-Cookie: isso-1=...; Expires=Wed, 18-Dec-2013 12:57:20 GMT; Max-Age=900; Path=/ +* { +* "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 +* } +*/ diff --git a/apidoc/apidoc.json b/apidoc/apidoc.json index 7adabee71..81766e126 100644 --- a/apidoc/apidoc.json +++ b/apidoc/apidoc.json @@ -2,8 +2,9 @@ "name": "Isso API", "title": "Isso API", "description": "", - "version": "0.13.0-dev", - "order": ["Thread", "Comment"], + "version": "0.13.0", + "order": ["Thread", "Comment", "Demo", "Admin", + "feed", "counts", "count", "config", "fetch"], "url" : "https://comments.example.com", "header": { "title": "Introduction", @@ -14,7 +15,6 @@ "filename": "footer.md" }, "template": { - "withCompare": false + "withCompare": "true" } } - diff --git a/apidoc/footer.md b/apidoc/footer.md index a81ddad13..dfdec1f55 100644 --- a/apidoc/footer.md +++ b/apidoc/footer.md @@ -1,8 +1,14 @@ # Help To generate this documentation: -```bash -git clone https://github.com/posativ/isso && cd isso -make apidoc-init apidoc -$BROWSER apidoc/_output/index.html -``` + +1. Install `Node.js` and `npm` +2. Run: + ```console + git clone https://github.com/posativ/isso && cd isso + make apidoc-init apidoc + ``` +3. View API documentation in browser at `./apidoc/_output/index.html`: + ```console + xdg-open apidoc/_output/index.html + ``` diff --git a/apidoc/header.md b/apidoc/header.md index 71c17534e..d19a6fa44 100644 --- a/apidoc/header.md +++ b/apidoc/header.md @@ -1,10 +1,29 @@ # Introduction This is the API documentation of the Isso commenting system. +**[Click here](/docs/)** to get back to the regular **Isso documentation**. + +### What can you do? + +- Fetch comment threads +- Post, edit and delete comments +- Get information about the server +- Like and dislike comments +- ...and much more! + +### Technical details + +These API docs are automatically generated by [apiDoc][apidoc] from +[isso/views/comments.py](https://github.com/posativ/isso/blob/master/isso/views/comments.py). + +You can select previous versions from a dropdown on the upper right of the +page. For more information about Isso, visit **[posativ.org/isso](https://posativ.org/isso)** or check out the source at **[GitHub](https://github.com/posativ/isso)**. +[apiDoc]: https://apidocjs.com/ + ### Conventions This documenation uses `https://comments.example.com` as the base URL for Isso's API. diff --git a/docs/docs/reference/server-api.rst b/docs/docs/reference/server-api.rst index b1ec74fd8..f9bf274c2 100644 --- a/docs/docs/reference/server-api.rst +++ b/docs/docs/reference/server-api.rst @@ -1,223 +1,91 @@ -API -==== +Server API +========== -.. note:: This information might be outdated. Isso's API documentation is built - using the ``apiDoc`` Javascript tool. +.. note:: View the `Current API documentation`_ for **Isso 0.12.6** here, which + is automatically generated. You can select previous versions from a dropdown + on the upper right of the page. - Run ``make apidoc-init apidoc`` and view the generated API documentation at - ``apidoc/_output/``. + Using the API, you can: - It is planned to add these generated API docs to the main Isso - documentation. For more information and progress, see the GitHub Issue - `Github Action should build and deploy api docs `_. + - Fetch comment threads + - Post, edit and delete comments + - Get information about the server + - Like and dislike comments + - **...and much more!** -The Isso API uses HTTP and JSON as primary communication protocol. +The Isso API uses ``HTTP`` and ``JSON`` as primary communication protocol. The +API is extensively documented using an `apiDoc`_-compatible syntax in +`isso/views/comments.py`_. -.. contents:: - :local: - - -JSON format ------------ - -When querying the API you either get a regular HTTP error, an object or list of -objects representing the comment. Here's an example JSON returned from -Isso: - -.. code-block:: json - - { - "id": 1, - "parent": null, - "text": "

Hello, World!

\n", - "mode": 1, - "hash": "4505c1eeda98", - "author": null, - "website": null, - "created": 1387321261.572392, - "modified": null, - "likes": 3, - "dislikes": 0 - } - -id : - comment id (unique per website). - -parent : - parent id reference, may be ``null``. - -text : - required, comment written in Markdown. - -mode : - * 1 – accepted - * 2 – in moderation queue - * 4 – deleted, but referenced. - -hash : - user identication, used to generate identicons. PBKDF2 from email or IP - address (fallback). - -author : - author's name, may be ``null``. - -website : - author's website, may be ``null``. - -likes : - upvote count, defaults to 0. - -dislikes : - downvote count, defaults to 0. - -created : - time in seconds since UNIX time. - -modified : - last modification since UNIX time, may be ``null``. - - -List comments -------------- - -List all publicly visible comments for thread `uri`: - -.. code-block:: text - - GET /?uri=%2Fhello-world%2F - -uri : - URI to fetch comments for, required. - -plain : - pass plain=1 to get the raw comment text, defaults to 0. +.. _Current API documentation: /docs/api/ +.. _apiDoc: https://apidocjs.com/ +.. _isso/views/comments.py: https://github.com/posativ/isso/blob/master/isso/views/comments.py +Sections covered in this document: -Get the latest N comments for all threads: - -.. code-block:: text +.. contents:: + :local: - GET /latest?limit=N +Generating API documentation +---------------------------- -The N parameter limits how many of the latest comments to retrieve; it's -mandatory, and must be an integer greater than 0. +Install ``Node.js`` and ``npm``. -This endpoint needs to be enabled in the configuration (see the -``latest-enabled`` option in the ``general`` section). +Run ``make apidoc-init apidoc`` and view the generated API documentation at +``apidoc/_output/`` (it produces a regular HTML file). +Live API testing +---------------- -Create comment --------------- +To test out calls to the API right from the browser, without having to +copy-&-paste ``curl`` commands, you can use ``apiDoc``'s live preview +functionality. -To create a new comment, you need to issue a POST request to ``/new`` and add -the current URI (so the server can check if the location actually exists). +Set ``sampleUrl`` to e.g. ``localhost:8080`` in ``apidoc.json``: -.. code-block:: bash +.. code-block:: json + :caption: apidoc.json - $ curl -vX POST http://isso/new?uri=%2F -d '{"text": "Hello, World!"}' -H "Content-Type: application/json" - < HTTP/1.1 201 CREATED - < Set-Cookie: 1=...; Expires=Wed, 18-Dec-2013 12:57:20 GMT; Max-Age=900; Path=/ { - "author": null, - "created": 1387370540.733824, - "dislikes": 0, - "email": null, - "hash": "6dcdbfb4f00d", - "id": 1, - "likes": 0, - "mode": 1, - "modified": null, - "parent": null, - "text": "

Hello, World!

\n", - "website": null + "name": "Isso API", + "version": "0.13.0", + "sampleUrl": "http://localhost:8080", + "private": "true" } -The payload must be valid JSON. To prevent CSRF attacks, you must set the -`Content-Type` to `application/json` or omit the header completely. - -The server issues a cookie per new comment which acts as authentication token -to modify or delete your own comment. The token is cryptographically signed -and expires automatically after 900 seconds by default. - -The following keys can be used to POST a new comment, all other fields are -dropped or replaced with values from the server: - -text : String - Actual comment, at least three characters long, required. - -author : String - Comment author, optional. - -website : String - Commenter's website (currently no field available in the client JS though), - optional. - -email : String - Commenter's email address (can be any arbitrary string though) used to - generate the identicon. Limited to 254 characters (RFC specification), - optional. - -parent : Integer - Reference to parent comment, optional. - - -Edit comment ------------- - -When your authentication token is not yet expired, you can issue a PUT request -to update `text`, `author` and `website`. After an update, you get an updated -authentication token and the comment as JSON: +Run ``make apidoc`` again and start your local +:ref:`test server ` -.. code-block:: bash +Go to ``apidoc/output`` and serve the generated API docs via +``python -m http.server`` [#f1]_, open ``http://localhost:8000`` in your browser +and use the "Send a sample request" - $ curl -X PUT http://isso/id/1 -d "..." -H "Content-Type: application/json" +.. image:: /images/apidoc-sample-latest.png + :scale: 75 % +.. [#f1] You must use a webserver to view the docs. Opening the local file + straight from the browser will not work; the browser will refuse to execute + any ``GET``/``POST`` calls because of security issues with local files. -Delete comment --------------- - -You can delete your own comments when your authentication token (= cookie) is -not yet expired: - -.. code-block:: bash - - $ curl -X DELETE http://isso/id/1 -H "Content-Type: application/json" - null - -Returns either `null` or a comment with an empty text value when the comment -is still referenced by other comments. - - -Up- and downvote comments +Writing API documentation ------------------------- -... - -Get comment count ------------------ - -Counts all publicly visible comments for thread `uri`: - -.. code-block:: text - - GET /count?uri=%2Fhello-world%2F - 2 - -uri : - URI to count comments for, required. - -returns an integer - -Get Atom feed -------------- - -Get an Atom feed of comments for thread `uri`: +Isso's API documentation is built using the `apiDoc`_ Javascript tool. -.. code-block:: text +Inside `isso/views/comments.py`_, the view functions that are public endpoints +are annotated using ``@api`` syntax in code comments. - GET /feed?uri=%2Fhello-world%2F +.. note:: The `apiDoc`_ "Getting started" guide should also help you get up to + speed in making the API documentation of Isso even better! -uri : - URI to get comments for, required. +A few points to consider: -Returns an XML document as the Atom feed. +- Use ``@apiVersion`` to annotate when an endpoint was first introduced or + changed. This information will help to automatically create a viewable diff + between Isso API versions. +- The current documentation for all endpoints should be good enough to + copy-paste for your new or changed endpoint. +- Admin functionality is marked ``@apiPrivate``. To generate docs for private + endpoints, set ``--private`` on the ``apidoc`` command line. +- Use ``@apiQuery`` for GET query URL-encoded parameters, ``@apiBody`` for POST + data. diff --git a/docs/images/apidoc-sample-latest.png b/docs/images/apidoc-sample-latest.png new file mode 100644 index 000000000..3b25d0768 Binary files /dev/null and b/docs/images/apidoc-sample-latest.png differ diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index 969a4260d..52e837e9a 100644 --- a/isso/tests/test_comments.py +++ b/isso/tests/test_comments.py @@ -391,25 +391,29 @@ def testFeed(self): def testCounts(self): - self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404) + rv = self.post('/count', data=json.dumps(['/path/'])) + self.assertEqual(rv.status_code, 200) + self.assertEqual(loads(rv.data), [0]) + self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "..."})) - rv = self.get('/count?uri=%2Fpath%2F') + rv = self.post('/count', data=json.dumps(['/path/'])) self.assertEqual(rv.status_code, 200) - self.assertEqual(loads(rv.data), 1) + self.assertEqual(loads(rv.data), [1]) for x in range(3): self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "..."})) - rv = self.get('/count?uri=%2Fpath%2F') + rv = self.post('/count', data=json.dumps(['/path/'])) self.assertEqual(rv.status_code, 200) - self.assertEqual(loads(rv.data), 4) + self.assertEqual(loads(rv.data), [4]) for x in range(4): self.delete('/id/%i' % (x + 1)) - rv = self.get('/count?uri=%2Fpath%2F') - self.assertEqual(rv.status_code, 404) + rv = self.post('/count', data=json.dumps(['/path/'])) + self.assertEqual(rv.status_code, 200) + self.assertEqual(loads(rv.data), [0]) def testMultipleCounts(self): diff --git a/isso/views/comments.py b/isso/views/comments.py index 9e7d76eb9..d45157a03 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -69,7 +69,7 @@ def xhr(func): """ @apiDefine csrf - @apiHeader {string="application/json"} Content-Type + @apiHeader {String="application/json"} Content-Type The content type must be set to `application/json` to prevent CSRF attacks. """ @@ -93,7 +93,6 @@ class API(object): VIEWS = [ ('fetch', ('GET', '/')), ('new', ('POST', '/new')), - ('count', ('GET', '/count')), ('counts', ('POST', '/count')), ('feed', ('GET', '/feed')), ('latest', ('GET', '/latest')), @@ -183,53 +182,61 @@ def verify(cls, comment): # Common definitions for apidoc follow: """ @apiDefine plainParam - @apiParam {number=0,1} [plain] + @apiQuery {Number=0,1} [plain=0] If 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 + @apiSuccess {Number} id The comment’s id (assigned by the server). - @apiSuccess {number} parent + @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 + @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 + @apiSuccess {String} author The comments’s author’s name or `null`. - @apiSuccess {string} website + @apiSuccess {String} website The comment’s author’s website or `null`. - @apiSuccess {string} hash + @apiSuccess {String} hash A hash uniquely identifying the comment’s author. - @apiSuccess {number} created + @apiSuccess {Number} created UNIX timestamp of the time the comment was created (on the server). - @apiSuccess {number} modified + @apiSuccess {Number} modified UNIX timestamp of the time the comment was last modified (on the server). `null` if the comment was not yet modified. """ + """ + @apiDefine admin Admin access needed + Only available to a logged-in site admin. Requires a valid `admin-session` cookie. + """ """ @api {post} /new create new @apiGroup Comment + @apiName new + @apiVersion 0.12.6 @apiDescription - Creates a new comment. The response will set a cookie on the requestor to enable them to later edit the comment. + Creates a new comment. The server issues a cookie per new comment which acts as + an authentication token to modify or delete the comment. + The token is cryptographically signed and expires automatically after 900 seconds (=15min) by default. @apiUse csrf - @apiParam {string} uri + @apiQuery {String} uri The uri of the thread to create the comment on. - @apiParam {string} text + @apiBody {String{3...65535}} text The comment’s raw text. - @apiParam {string} [author] + @apiBody {String} [author] The comment’s author’s name. - @apiParam {string} [email] + @apiBody {String{...254}} [email] The comment’s author’s email address. - @apiParam {string} [website] - The comment’s author’s website’s url. - @apiParam {number} [parent] + @apiBody {String{...254}} [website] + The comment’s author’s website’s url. Must be Django-conform, i.e. either `http(s)://example.com/foo` or `example.com/` + @apiBody {Number} [parent] The parent comment’s id if the new comment is a response to an existing comment. @apiExample {curl} Create a reply to comment with id 15: @@ -237,7 +244,10 @@ def verify(cls, comment): @apiUse commentResponse - @apiSuccessExample Success after the above request: + @apiSuccessExample {json} Success after the above request: + HTTP/1.1 201 CREATED + Set-Cookie: 1=...; Expires=Wed, 18-Dec-2013 12:57:20 GMT; Max-Age=900; Path=/; SameSite=Lax + X-Set-Cookie: isso-1=...; Expires=Wed, 18-Dec-2013 12:57:20 GMT; Max-Age=900; Path=/; SameSite=Lax { "website": null, "author": "Max Rant", @@ -256,7 +266,7 @@ def verify(cls, comment): @requires(str, 'uri') def new(self, environ, request, uri): - data = request.get_json() + data = request.json for field in set(data.keys()) - API.ACCEPT: data.pop(field) @@ -372,10 +382,12 @@ def create_cookie(self, **kwargs): """ @api {get} /id/:id view @apiGroup Comment + @apiName view + @apiVersion 0.12.6 @apiDescription - View an existing comment, for the purpose of editing. 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. + View an existing comment, for the purpose of editing. Editing a comment is only possible for a short period of time (15min by default) 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 + @apiParam {Number} id The id of the comment to view. @apiUse plainParam @@ -398,7 +410,6 @@ def create_cookie(self, **kwargs): "likes": 1 } """ - def view(self, environ, request, id): rv = self.comments.get(id) @@ -421,25 +432,28 @@ def view(self, environ, request, id): """ @api {put} /id/:id edit @apiGroup Comment + @apiName edit + @apiVersion 0.12.6 @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. + Edit an existing comment. Editing a comment is only possible for a short period of time (15min by default) 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 + @apiParam {Number} id The id of the comment to edit. - @apiParam {string} text + @apiBody {String{3...65535}} text A new (raw) text for the comment. - @apiParam {string} [author] + @apiBody {String} [author] The modified comment’s author’s name. - @apiParam {string} [webiste] - The modified comment’s author’s website. + @apiBody {String{...254}} [website] + The modified comment’s author’s website. Must be Django-conform, i.e. either `http(s)://example.com/foo` or `example.com/` @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: + @apiSuccessExample {json} Example response: + HTTP/1.1 200 OK { "website": "maxrant.important.com", "author": "Max Rant", @@ -468,7 +482,7 @@ def edit(self, environ, request, id): if rv[1] != sha1(self.comments.get(id)["text"]): raise Forbidden - data = request.get_json() + data = request.json if "text" not in data or data["text"] is None or len(data["text"]) < 3: raise BadRequest("no text given") @@ -498,22 +512,48 @@ def edit(self, environ, request, id): return resp """ - @api {delete} '/id/:id' delete + @api {delete} /id/:id delete @apiGroup Comment + @apiName delete + @apiVersion 0.12.6 @apiDescription - Delete 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. + Delete an existing comment. Deleting a comment is only possible for a short period of time (15min by default) 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. + Returns either `null` or a comment with an empty text value when the comment is still referenced by other comments. + @apiUse csrf - @apiParam {number} id + @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: + @apiSuccessExample Successful deletion returns null and deletes cookie: + HTTP/1.1 200 OK + Set-Cookie 14=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax + X-Set-Cookie 14=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax + null + + @apiSuccessExample {json} Comment still referenced by another: + HTTP/1.1 200 OK + Set-Cookie 14=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax + X-Set-Cookie 14=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax + { + "id": 14, + "parent": null, + "created": 1653432621.0512516, + "modified": 1653434488.571937, + "mode": 4, + "text": "", + "author": null, + "website": null, + "likes": 0, + "dislikes": 0, + "notification": 0 + } """ @xhr - def delete(self, environ, request, id, key=None): + def delete(self, environ, request, id): try: rv = self.isso.unsign(request.cookies.get(str(id), "")) @@ -554,29 +594,30 @@ def delete(self, environ, request, id, key=None): """ @api {get} /id/:id/unsubscribe/:email/:key unsubscribe @apiGroup Comment + @apiName unsubscribe + @apiVersion 0.12.6 @apiDescription Opt out from getting any further email notifications about replies to a particular comment. In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso. - @apiParam {number} id + @apiParam {Number} id The id of the comment to unsubscribe from replies to. - @apiParam {string} email + @apiParam {String} email The email address of the subscriber. - @apiParam {string} key + @apiParam {String} key The key to authenticate the subscriber. @apiExample {curl} Unsubscribe Alice from replies to comment with id 13: curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos' @apiSuccessExample {html} Using GET: - <!DOCTYPE html> - <html> - <head>Successfully unsubscribed</head> - <body> - <p>You have been unsubscribed from replies in the given conversation.</p> - </body> - </html> + + + .commit-tease, .user-profile-mini-avatar, .avatar, .vcard-details, .signup-prompt-bg { display: none !IMPORTANT; } + +

You have been unsubscribed from replies in the given conversation.

+ + """ - def unsubscribe(self, environ, request, id, email, key): email = unquote(email) @@ -615,27 +656,30 @@ def unsubscribe(self, environ, request, id, email, key): """ @api {post} /id/:id/:action/:key moderate @apiGroup Comment + @apiName moderate + @apiVersion 0.12.6 @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. - + 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 or provided in the admin interface. 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 + @apiParam {Number} id The id of the comment to moderate. - @apiParam {string=activate,edit,delete} action - `activate` to publish the comment (change its mode to `1`). - `delete` to delete the comment - @apiParam {string} key + @apiParam {String=activate,edit,delete} action + - `activate` to publish the comment (change its mode to `1`). + - `edit`: Send `text`, `author`, `email` and `website` via `POST`. + To be used from the admin interface. Better use the `edit` `PUT` endpoint. + - `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> + @apiSuccessExample {html} Request deletion using GET: + + + + - @apiSuccessExample Using POST: + @apiSuccessExample Delete using POST: Comment has been deleted - """ + @apiSuccessExample Activate using POST: + Comment has been activated + """ def moderate(self, environ, request, id, action, key): try: id = self.isso.unsign(key, max_age=2**32) @@ -689,7 +735,7 @@ def moderate(self, environ, request, id, action, key): self.signal("comments.activate", thread, item) return Response("Comment has been activated", 200) elif action == "edit": - data = request.get_json() + data = request.json with self.isso.lock: rv = self.comments.update(id, data) for key in set(rv.keys()) - API.FIELDS: @@ -704,88 +750,92 @@ def moderate(self, environ, request, id, action, key): self.signal("comments.delete", id) return Response("Comment has been deleted", 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 comment. 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 top-level comments. - @apiSuccess {number} hidden_replies - The number of comments that were omitted 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 response: + """ + @api {get} / Get comments + @apiGroup Thread + @apiName fetch + @apiVersion 0.12.6 + @apiDescription Queries the publicly visible comments of a thread. + + @apiQuery {String} uri + The URI of thread to get the comments from. + @apiQuery {Number} [parent] + Return only comments that are children of the comment with the provided ID. + @apiUse plainParam + @apiQuery {Number} [limit] + The maximum number of returned top-level comments. Omit for unlimited results. + @apiQuery {Number} [nested_limit] + The maximum number of returned nested comments per comment. Omit for unlimited results. + @apiQuery {Number} [after] + Includes only comments were added after the provided UNIX timestamp. + + @apiSuccess {Number} id + Id of the comment `replies` is the list of replies of. `null` for the list of top-level comments. + @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 {Number} hidden_replies + The number of comments that were omitted from the results because of the `limit` request parameter. Usually, this will be `total_replies` - `limit`. + @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 {Object[]} config + Object holding only the client configuration parameters that depend on server settings. Will be dropped in a future version of Isso. Use the dedicated `/config` endpoint instead. + + @apiExample {curl} Get 2 comments with 5 responses: + curl 'https://comments.example.com/?uri=/thread/&limit=2&nested_limit=5' + @apiSuccessExample {json} Example response: + { + "total_replies": 14, + "replies": [ { - "total_replies": 14, + "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": 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, + "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, - "replies": [], - "hash": "1cb6cc0309a2", - "id": 3, - "likes": 0 - }, - "id": null, - "hidden_replies": 12 - } - """ + "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): @@ -899,31 +949,33 @@ def _process_fetched_list(self, fetched_list, plain=False): """ @apiDefine likeResponse - @apiSuccess {number} likes + @apiSuccess {Number} likes The (new) number of likes on the comment. - @apiSuccess {number} dislikes + @apiSuccess {Number} dislikes The (new) number of dislikes on the comment. + @apiSuccessExample Return updated vote counts: + { + "likes": 4, + "dislikes": 3 + } """ """ @api {post} /id/:id/like like @apiGroup Comment + @apiName like + @apiVersion 0.12.6 @apiDescription - Puts a “like” on a comment. The author of a comment cannot like its own comment. + Puts a “like” on a comment. The author of a comment cannot like their own comment. + @apiUse csrf - @apiParam {number} id + @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): @@ -934,22 +986,19 @@ def like(self, environ, request, id): """ @api {post} /id/:id/dislike dislike @apiGroup Comment + @apiName dislike + @apiVersion 0.12.6 @apiDescription - Puts a “dislike” on a comment. The author of a comment cannot dislike its own comment. + Puts a “dislike” on a comment. The author of a comment cannot dislike their own comment. + @apiUse csrf - @apiParam {number} id + @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): @@ -957,32 +1006,56 @@ def dislike(self, environ, request, id): nv = self.comments.vote(False, id, self._remote_addr(request)) return JSON(nv, 200) - # TODO: remove someday (replaced by :func:`counts`) - @requires(str, 'uri') - def count(self, environ, request, uri): + """ + @api {post} /preview preview + @apiGroup Comment + @apiName preview + @apiVersion 0.12.6 + @apiDescription + Render comment text using markdown. - rv = self.comments.count(uri)[0] + @apiBody {String{3...65535}} text + (Raw) comment text - if rv == 0: - raise NotFound + @apiSuccess {String} text + Rendered comment text - return JSON(rv, 200) + @apiExample {curl} Preview comment: + curl -X POST 'https://comments.example.com/preview' -d '{"text": "A sample comment"}' + @apiSuccessExample {json} Rendered comment: + { + "text": "

A sample comment

" + } """ - @api {post} /count count comments + def preview(self, environment, request): + data = request.json + + if "text" not in data or data["text"] is None: + raise BadRequest("no text given") + + return JSON({'text': self.isso.render(data["text"])}, 200) + + """ + @api {post} /count Count comments @apiGroup Thread + @apiName counts + @apiVersion 0.12.6 @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 responses to comments. + 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 responses to comments, but only published comments (i.e. exclusing comments pending moderation). - @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"] + @apiBody {Number[]} urls + Array of URLs for which to fetch comment counts - @apiSuccessExample Counts of 5 threads: + @apiExample {curl} Get the respective counts of 5 threads: + curl -X POST 'https://comments.example.com/count' -d '["/blog/firstPost.html", "/blog/controversalPost.html", "/blog/howToCode.html", "/blog/boringPost.html", "/blog/isso.html"] + + @apiSuccessExample {json} Counts of 5 threads: [2, 18, 4, 0, 3] """ def counts(self, environ, request): - data = request.get_json() + data = request.json if not isinstance(data, list) and not all(isinstance(x, str) for x in data): raise BadRequest("JSON must be a list of URLs") @@ -992,8 +1065,44 @@ def counts(self, environ, request): """ @api {get} /feed Atom feed for comments @apiGroup Thread + @apiName feed + @apiVersion 0.12.6 @apiDescription - Provide an Atom feed for the given thread. + Provide an Atom feed for the given thread. Only available if `[rss] base` is set in server config. By default, up to 100 comments are returned. + + @apiQuery {String} uri + The uri of the thread to display a feed for + + @apiExample {curl} Get an Atom feed for /thread/foo in XML format: + curl 'https://comments.example.com/feed?uri=/thread/foo' + + @apiSuccessExample Atom feed for /thread/foo: + + + 2022-05-24T20:38:04.032789Z + tag:example.com,2018:/isso/thread/thread/foo + Comments for example.com/thread/foo + + tag:example.com,2018:/isso/1/2 + Comment #2 + 2022-05-24T20:38:04.032789Z + + John Doe + + + <p>And another</p> + + + tag:example.com,2018:/isso/1/1 + Comment #1 + 2022-05-24T20:38:00.837703Z + + Jane Doe + + + <p>A sample comment</p> + + """ @requires(str, 'uri') def feed(self, environ, request, uri): @@ -1108,34 +1217,35 @@ def feed(self, environ, request, uri): response.last_modified = comment0['modified'] or comment0['created'] return response.make_conditional(request) - def preview(self, environment, request): - data = request.get_json() - - if "text" not in data or data["text"] is None: - raise BadRequest("no text given") - - return JSON({'text': self.isso.render(data["text"])}, 200) - """ - @api {get} /config fetch client config + @api {get} /config Fetch client config @apiGroup Thread + @apiName config + @apiVersion 0.12.6 @apiDescription - Returns only the client configuration parameters that depend on server settings. The following settings are sent as a `config` object from the server to the client: - - reply-to-self - require-author - require-email - reply-notifications - gravatar - avatar # if gravatar==true + Returns only the client configuration parameters that depend on server settings. @apiSuccess {Object[]} config The client configuration object. + @apiSuccess {Boolean} config.reply-to-self + Commenters can reply to their own comments. + @apiSuccess {Boolean} config.require-author + Commenters must enter valid Name. + @apiSuccess {Boolean} config.require-email + Commenters must enter valid email. + @apiSuccess {Boolean} config.reply-notifications + Enable reply notifications via E-mail. + @apiSuccess {Boolean} config.gravatar + Load images from Gravatar service instead of generating them. Also disables regular avatars (see below). + @apiSuccess {Boolean} config.avatar + To avoid having both regular avatars and Gravatars side-by-side, + setting `gravatar` will disable regular avatars. The `avatar` key will + only be sent by the server if `gravatar` is set. @apiExample {curl} get the client config: curl 'https://comments.example.com/config' - @apiSuccessExample Client config: + @apiSuccessExample {json} Client config: { "config": { "reply-to-self": false, @@ -1151,11 +1261,67 @@ def config(self, environment, request): rv = {'config': self.public_conf} return JSON(rv, 200) + """ + @api {get} /demo Isso demo page + @apiGroup Demo + @apiName demo + @apiVersion 0.12.6 + @apiPrivate + @apiDescription + Displays a demonstration of Isso with a thread counter and comment widget. + + @apiExample {curl} Get demo page + curl 'https://comments.example.com/demo/index.html' + + @apiSuccessExample {html} Demo page: + + + Isso Demo + + + + +
+
+

Isso Demo

+ +
+

This is a link to a thead, which will display a comment counter: + How many Comments?

+

Below is the actual comment field.

+
+
+
+
+ + """ def demo(self, env, req): return redirect( get_current_url(env, strip_querystring=True) + '/index.html' ) + """ + @api {post} /login Log in + @apiGroup Admin + @apiName login + @apiVersion 0.12.6 + @apiPrivate + @apiDescription + Log in to admin, will redirect to `/admin` on success. Must use form data, not `POST` JSON. + + @apiBody {String} password + The admin password as set in `[admin] password` in the server config. + + @apiExample {curl} Log in + curl -X POST 'https://comments.example.com/login' -F "password=strong_default_password_for_isso_admin" -c cookie.txt + + @apiSuccessExample {html} Login successful: + + + Redirecting... +

Redirecting...

+

You should be redirected automatically to the target URL: https://comments.example.com/admin. If not, click the link. + """ def login(self, env, req): if not self.isso.conf.getboolean("admin", "enabled"): isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host @@ -1177,6 +1343,33 @@ def login(self, env, req): isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host return render_template('login.html', isso_host_script=isso_host_script) + """ + @api {get} /admin Admin interface + @apiGroup Admin + @apiName admin + @apiVersion 0.12.6 + @apiPrivate + @apiPermission admin + @apiDescription + Display an admin interface from which to manage comments. Will redirect to `/login` if not already logged in. + + @apiQuery {Number} [page=0] + Page number + @apiQuery {Number{1,2,4}} [mode=2] + 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. + @apiQuery {String{id,created,modified,likes,dislikes,tid}} [order_by=created] + Comment ordering + @apiQuery {Number{0,1}} [asc=0] + Ascending + + @apiExample {curl} Listing of published comments: + curl 'https://comments.example.com/admin?mode=1&page=0&order_by=modified&asc=1' -b cookie.txt + """ def admin(self, env, req): isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host if not self.isso.conf.getboolean("admin", "enabled"): @@ -1212,10 +1405,12 @@ def admin(self, env, req): """ @api {get} /latest latest @apiGroup Comment + @apiName latest + @apiVersion 0.12.6 @apiDescription - Get the latest comments from the system, no matter which thread + Get the latest comments from the system, no matter which thread. Only available if `[general] latest-enabled` is set to `true` in server config. - @apiParam {number} limit + @apiQuery {Number} limit The quantity of last comments to retrieve @apiExample {curl} Get the latest 5 comments @@ -1253,11 +1448,12 @@ def admin(self, env, req): } ] """ - def latest(self, environ, request): # if the feature is not allowed, don't present the endpoint if not self.conf.getboolean("latest-enabled"): - return NotFound() + return NotFound( + "Unavailable because 'latest-enabled' not set by site admin" + ) # get and check the limit bad_limit_msg = "Query parameter 'limit' is mandatory (integer, >0)"