├── LICENSE ├── PicoComments.php ├── README.md └── comments.twig /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /comments.twig: -------------------------------------------------------------------------------- 1 | {% macro print_comments(comments, reply) %} 2 | {% for comment in comments %} 3 |
34 | {% endif %} 35 | 48 | {% else %} 49 | Comment submission is disabled for this page 50 | {% endif %} 51 | {% if comments is defined %} 52 |
{{ comment.content|raw }}
7 | Reply 8 | 9 | 22 | {% if comment.replies is not empty %} 23 | {{ _self.print_comments(comment.replies, true) }} 24 | {% endif %} 25 |