├── LICENSE
├── comments.twig
├── README.md
└── PicoComments.php
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Kiernan Roche
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/comments.twig:
--------------------------------------------------------------------------------
1 | {% macro print_comments(comments, reply) %}
2 | {% for comment in comments %}
3 |
26 | {% endfor %}
27 | {% endmacro %}
28 |
29 | {% if meta.comments %}
30 | {% if comments_message is defined %}
31 |
34 | {% endif %}
35 |
48 | {% else %}
49 |
50 | {% endif %}
51 | {% if comments is defined %}
52 | {{ comments_number }} comments
53 | {{ _self.print_comments(comments, false) }}
54 | {% else %}
55 | No comments yet
56 | {% endif %}
57 |
58 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # pico-comments
2 |
3 | This plugin implements comments for Pico CMS.
4 |
5 | I wrote this for my own personal use, but felt that others might get some use out of it because a comments plugin for Pico did not exist yet.
6 |
7 | Features
8 | ---
9 | - Comments stored in a similar manner to pages
10 | - Comment replies
11 | - Comment review
12 | - Comment size limits
13 | - Antispam using a honeypot form field
14 | - IP address logging
15 | - Theoretically scalable
16 |
17 | Usage and configuration
18 | ---
19 | To use this plugin, you need to:
20 | 1. Add PicoComments.php to a folder in your Pico plugins directory, e.g ; `pico/plugins/PicoComments`
21 | ```
22 | pico/plugins
23 | ├── PicoComments
24 | ├── comments.twig
25 | ├── LICENSE
26 | ├── PicoComments.php
27 | └── README.md
28 | ```
29 | 2. Configure and enable it in your `config.yml` by adding the following section :
30 | ```yml
31 | PicoComments:
32 | # whether the comments plugin is enabled. comment data will not be available to Twig if this is false
33 | enabled: true
34 | # maximum character size of comments. comments larger than this will be rejected
35 | comment_size_limit: 1000
36 | # whether comments must be approved before displaying them to other users
37 | comment_review: false
38 | ```
39 | 3. Put the `comments.twig` file in your theme's folder, e.g `pico/themes/default`;
40 | ```
41 | pico
42 | └─ themes
43 | └─ default
44 | ├── [...]
45 | └── comments.twig
46 | ```
47 | then in your theme's `index.twig` file, add the following [twig instruction](https://twig.symfony.com/doc/1.x/tags/include.html) :
48 | ```php
49 | {{ include('comments.twig') }}
50 | ```
51 | where you want the form to appear.
52 |
53 | 4. Comment submission can be enabled/disabled for each page in that page's frontmatter:
54 | ```
55 | ---
56 | comments: true
57 | ---
58 | ```
59 |
60 | If ```comments``` is set to ```false```, existing comments on that page will still be available to Twig for rendering, but no new comments will be accepted.
61 |
62 | Comment data and comment submission success/error messages are exposed as Twig variables. Please see comments.twig for an example comment submission form and comment rendering code.
63 |
64 | Comments that are submitted while ```comment_review``` is enabled in the site config are not exposed to Twig. To approve a comment that is under review, open it with a text editor and change the "pending" field from "true" to "false". To deny it, leave "pending" as "true" or delete the comment file from disk.
65 |
66 | Technical details
67 | ---
68 | Comments are created by submitting a POST request to the URL of a page with comments enabled. The plugin looks for these parameters:
69 | - comment_author
70 | - comment_content
71 | - comment_replyguid must be included if the comment is a reply to another comment
72 |
73 | All other comment properties (guid, date of posting, ip address, pending review) are generated on the server.
74 |
75 | Comment content and author names are sanitized before being saved. Comment reply GUIDs are validated before the comment is saved, and if the comment pointed to by the reply GUID does not exist, the comment will not be accepted.
76 |
77 | Comments are stored as files in a a folder named ```blog-comments``` in a manner very similar to Pico's own page structure.
78 | If the folder creation fails, please create the folder yourself next to the ``ìndex.php`` and give it the correct access rights. Named 0755 for the file mode and the WWW user as owner. So under Linux for example via:
79 | ```
80 | cd picocms
81 | cd picocms
82 | mkdir blog-comments
83 | chmod 0775 blog-comments && chown www-data:www-data blog-comments
84 | ```
85 |
86 | The structure of a comment file is as follows:
87 | ```
88 | ---
89 | guid
90 | guid of comment being replied to, if this comment is in reply to another comment
91 | date posted, in UNIX time
92 | ip address of author
93 | author name
94 | pending status
95 | ---
96 | content
97 | ```
98 |
99 | Files are named using the comment guid followed by a ```.md``` extension, though I chose not to render the comment content as Markdown to avoid cross-site scripting issues.
100 |
101 | Comment storage design
102 | ---
103 | I chose to store comments on disk as a parent-pointer tree, because a child-pointer representation would require comments to maintain a list of children which is updated each time the comment is replied to, which is not necessary with a parent-pointer tree. However, a parent-pointer tree is much harder to recurse from the top down, which is what we want when rendering the comments; creating a parent comment as a div and recursing downward to create its children inside of it is much simpler than recursing upward and storing child comments in memory until we reach the top of the tree and then rendering downwards somehow (this is the sort of thing that should **not** be done in Twig). We achieve the best of both schemes by storing comments as parent-pointer and exposing them to Twig as child-pointer, making it easy for Twig to render replies recursively without complex logic. Using both tree types necessitates some means of transforming one to the other, which is the core logic of this plugin.
104 |
105 | First, comments are read from disk into memory. Each comment is stored as a dictionary of its frontmatter (meta) keys to the values corresponding to them, plus a "content" key that contains its content:
106 | ```
107 | Array
108 | (
109 | [guid] => ce7b48f8ce53dff6af8a3f4b5b9261d6
110 | [reply_guid] => d3b964839536a5fdb1006e6f9d9140e2
111 | [date] => 2020-08-20 15:25:27
112 | [ip] => 127.0.0.1
113 | [author] => test5
114 | [time] => 1597951527
115 | [date_formatted] => August 08, 2020 15:25
116 | [content] => Hello, world!
117 | )
118 | ```
119 |
120 | A PHP dictionary is used to store comments: comment GUIDs as keys, arrays which contain children of that comment as values. If a comment has a parent, it's placed in the array corresponding to that parent's GUID. If it doesn't have a parent (top level comment), it's placed in an array with a null key ([""]).
121 |
122 | Once we have established the comment structure in memory, we transform it. The transformation algorithm works like this:
123 | ```
124 | Given an array of comments (starting with the array of top level comments corresponding to the null key), for each comment in the array:
125 | If this comment's GUID is a key in the dictionary (it has children):
126 | Recurse into the array corresponding to that GUID (recursion happens first so that children are fully populated with their own descendants before moving them)
127 | Set this comment's replies array equal to that array of children, making a copy inside this comment
128 | Sort this comment's replies array by date
129 | Remove array of children from the dictionary now that it is contained within this comment
130 | ```
131 |
132 | Once the tree is transformed, we expose it to Twig as the ```comments``` variable, which can be recursed using a Twig macro to render it. We can access the total number of comments in Twig using the ```comments_number``` variable.
133 |
134 | The use of a dictionary to build the child-pointer tree eliminates any need for searching, so this plugin should scale to large numbers of comments, but I haven't tested it with anything more than a few dozen comments.
135 |
136 | Issues, assumptions, future ideas
137 | ---
138 | - Comments are stored in a hierarchy that mirrors the structure of the site. If the site structure changes, the comments structure needs to be changed to match. This could be addressed by changing the design to store the page ID within each comment and all comments in a single directory, but I decided against that, instead keeping comments for each page in a directory corresponding to that page, which improves performance by limiting the tree-building loops only to the comments on the current page.
139 | - There is no captcha, but the current antispam solution should suffice unless someone writes a bot specifically to ignore the honeypot form field. IP addresses are stored, so IP-based antispam would be straightforward to implement.
140 | - Users cannot delete comments, because no user authentication is implemented. A simple approach might be to use PHP sessions to allow users to delete their comments as long as their session is valid, but not after their session cookie expires and they are no longer identifiable.
141 | - The plugin responds to a POST request directly with a newly rendered page rather than issuing a redirect to the same page. This means that if the user refreshes the page after submitting a comment, they may submit a duplicate comment. I chose to do this because submission messages (success/error, etc.) are exposed as Twig variables, which means they will not survive a redirect since they exist on a per-request basis. Once again, sessions could address this.
142 | - There is some code duplication between the print_comments macro and the top-level submission form. This could be resolved with another macro.
143 |
144 | Contributing
145 | ---
146 | I welcome suggestions, comments, and code.
147 | - If you feel your contribution would improve this plugin for the general use case, please submit a pull request.
148 | - If you find a bug, open an issue and describe what's happening. Please include verbose PHP and web server logs (with sensitive info redacted) and screenshots of the issue, where applicable.
149 |
150 | I don't have a lot of time to continue development of this plugin except for my own purposes. I can't guarantee that I will fix issues or implement new features promptly, or at all. If I do, and if I believe such improvements would be useful to users, I will release them.
151 |
--------------------------------------------------------------------------------
/PicoComments.php:
--------------------------------------------------------------------------------
1 | $this->getPluginConfig("comment_size_limit")) {
28 | return false;
29 | }
30 |
31 | // path where the current page's comments are stored
32 | $path = $this->content_path . "/" . $this->id;
33 |
34 | if (!file_exists($path)) {
35 | mkdir($path, 0755, true);
36 | } else if (isset($reply_guid)) {
37 | $reply_exists = false; // whether $reply_guid corresponds to a real comment. false by default
38 | $dir = glob($path . "/*.md");
39 | foreach ($dir as $file) { // check if the comment pointed to by reply_guid exists, fail if not
40 | if (array_slice(explode("/", explode(".", $file)[0]), -1, 1)[0] == $reply_guid) {
41 | $reply_exists = true;
42 | break;
43 | }
44 | }
45 | if (!$reply_exists) { // if the comment that reply_guid points to can't be found,
46 | return false; // don't create the comment
47 | }
48 | }
49 |
50 | // build the comment file
51 | // this is hacky
52 | $file_contents = "---\n";
53 | $file_contents .= "guid: " . $guid . "\n";
54 | if (isset($reply_guid)) { $file_contents .= "reply_guid: " . $reply_guid . "\n"; }
55 | $file_contents .= "date: " . strval($date) . "\n";
56 | $file_contents .= "ip: " . strval($ip) . "\n";
57 | $file_contents .= "author: " . $author . "\n";
58 | // if comment review is enabled, add header
59 | if ($this->getPluginConfig("comment_review")) { $file_contents .= "pending: true\n"; }
60 | $file_contents .= "---\n";
61 | $file_contents .= $content;
62 |
63 | $handle = fopen($path . "/" . $guid . ".md", "w");
64 | if (!fwrite($handle, $file_contents)) { // if file writing fails for some reason
65 | fclose($handle);
66 | return false;
67 | }
68 | fclose($handle);
69 | return true;
70 | }
71 |
72 | // return a nested array of hashes
73 | // [{"author":"me", "content":"hello", "replies":[{"author":"him", "content":"hi there"}]}]
74 | // replies to each comment are stored as a sub-array which can be recursed through in twig
75 | private function getComments() {
76 | $comments = []; // this is the dictionary where comment dictionaries will be stored
77 |
78 | $dir = glob($this->content_path . "/" . $this->id . "/*.md");
79 | // this loop reads comments from disk into memory as a dictionary
80 | // for each file in the content page dir:
81 | foreach ($dir as $file) {
82 | // read in the file
83 | try {
84 | // IP address left out to prevent leaks to users. it's for administrative use only i.e. blocking spam
85 | $headers = [
86 | "GUID" => 'guid',
87 | "Reply GUID" => 'reply_guid',
88 | "Author" => 'author',
89 | "Date" => 'date',
90 | "Pending Review" => 'pending'
91 | ];
92 |
93 | // parse headers and content
94 | $meta = $this->parseFileMeta(file_get_contents($file), $headers);
95 | // Pico's parseFileContent function doesn't ignore frontmatter so I'm rolling my own here
96 | $content = explode("---\n", file_get_contents($file))[2];
97 | } catch (\Symfony\Component\Yaml\Exception\ParseException $e) {
98 | error_log($e->getMessage());
99 | // continue to next loop iteration, we don't want improperly parsed comments to exist in the dictionary
100 | continue;
101 | }
102 |
103 | // build the dictionary entry
104 | if (!$meta['pending']) { // if the comment is not pending review or has been approved
105 | $comment = [
106 | 'content' => $content
107 | ];
108 | foreach($meta as $key => $value) {
109 | if (strlen($meta[$key]) > 0) {
110 | $comment[$key] = $value;
111 | }
112 | }
113 |
114 | // insert into dict
115 | if (strlen($meta['reply_guid']) > 0) {
116 | $comments[$meta['reply_guid']][] = $comment;
117 | } else {
118 | $comments[""][] = $comment;
119 | }
120 |
121 | // increment the comments counter
122 | $this->num_comments++;
123 | }
124 | }
125 |
126 | // this recursive function builds and sorts the child-pointer tree
127 | function insert_replies(&$array, &$comments) {
128 | if (!is_array($array) || !is_object($array))
129 | return;
130 | foreach($array as &$comment) {
131 | // if this comment has children,
132 | if (isset($comments[$comment['guid']])) {
133 | // recurse first so children are fully populated with their own descendants
134 | insert_replies($comments[$comment['guid']], $comments);
135 | // insert children into this comment's replies array
136 | $comment['replies'] = $comments[$comment['guid']];
137 | // sort the replies
138 | usort($comment['replies'], function($a, $b) { return $b['date'] <=> $a['date']; });
139 | // remove descendants from the top level
140 | unset($comments[$comment['guid']]);
141 | }
142 | }
143 |
144 | // any replies that exist have been inserted
145 | return;
146 | }
147 |
148 | // build the tree
149 | insert_replies($comments[""], $comments);
150 |
151 | // remove the extra array wrapper around the top level
152 | $comments = $comments[""];
153 | // sort the top level
154 | if (!is_array($comments) && !is_object($comments))
155 | return;
156 | usort($comments, function($a, $b) { return $b['date'] <=> $a['date']; });
157 | return $comments;
158 | }
159 |
160 | public function onMetaParsed(array &$headers) {
161 | $this->headers = $headers; // store the headers of the current page
162 | }
163 |
164 | public function onPageRendering(&$templateName, array &$twigVariables) {
165 | if (!isset($this->headers['comments'])) { // in this case, we assume that this page does not support comments
166 | return; // do nothing
167 | }
168 |
169 | $this->id = $this->pico->getCurrentPage()['id'];
170 |
171 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { // if a comment is submitted
172 | if ($this->headers['comments'] != "enabled") { // if comment submission is disabled on this page, do nothing
173 | $twigVariables['comments_message'] = "Comment submission is disabled on this page"; // display error message and status 1
174 | $twigVariables['comments_message_status'] = 1;
175 | return;
176 | }
177 |
178 | // check if antispam honeypot is filled out
179 | if (isset($_POST['website']) && strlen($_POST['website']) > 0) {
180 | return;
181 | }
182 |
183 | if (!isset($_POST['comment_author']) || !isset($_POST['comment_content'])) { // if we are missing comment author or content
184 | $twigVariables['comments_message'] = "Please fill out all required fields.";// display error message and status 1
185 | $twigVariables['comments_message_status'] = 1;
186 | }
187 |
188 | $reply_guid = isset($_POST['comment_replyguid']) ? $_POST['comment_replyguid'] : null; // set reply_guid to null if it comment_replyguid is not included (i.e. if this comment is not a reply)
189 |
190 | if ($this->createComment($_POST['comment_author'], $_POST['comment_content'], $reply_guid)) { // if CreateComment is successful
191 | $twigVariables['comments_message'] = "Comment submitted"; // display success message and status 0
192 | $twigVariables['comments_message_status'] = 0;
193 | } else { // if createComment fails for some reason
194 | $twigVariables['comments_message'] = "Comment not submitted"; // display fail message and status 1
195 | $twigVariables['comments_message_status'] = 1;
196 | }
197 |
198 | $twigVariables['comments'] = $this->getComments() ?: "Server error";// display comments or fail, since we want to display comments after a new comment has been submitted to show the user their new comment
199 | $twigVariables['comments_number'] = $this->num_comments ?: "0";
200 |
201 | } else if ($_SERVER['REQUEST_METHOD'] === 'GET') { // if this is a GET request
202 | $twigVariables['comments'] = $this->getComments() ?: "Server error";// display comments or fail
203 | $twigVariables['comments_number'] = $this->num_comments ?: "0";
204 | }
205 | }
206 | }
--------------------------------------------------------------------------------
{{ comment.content|raw }}
7 | Reply 8 | 9 | 22 | {% if comment.replies is not empty %} 23 | {{ _self.print_comments(comment.replies, true) }} 24 | {% endif %} 25 |