Added translations, antispam, notifications and subscriptions.

This commit is contained in:
Emidio Reggiani 2025-12-26 12:25:57 +01:00
commit 22a2ca5b21
6 changed files with 369 additions and 36 deletions

View file

@ -402,4 +402,24 @@ comment_submission_error_email = "Valid email is required."
comment_submission_error_short = "Comment is required and must be at least 3 characters."
comment_submission_error_spam = "SPAM detected!"
pending_comments = "Pending Comments"
level = "Level"
level = "Level"
enable_jstime="Enable Javascript and timestamp anti-spam protection"
jstime_desc="Usually bots dont't use Javascript. Form also checks if submitted between 3 and 600 seconds (preventing bots fast submission)"
comment_email_admin_subject="New comment awaiting moderation"
comment_email_subscription_subject = "Subscription confirmation to"
comment_email_new = "New comment on"
comment_email_from = "From"
comment_email_moderate = "Moderate comments"
comment_email_new_subscribed = "New reply on a subscribed thread"
comment_email_new_replied = "Someone replied to your comment on"
comment_email_view_comment = "View comment"
comment_subscribe_confirmation = "Subscription confirmation to"
comment_subscribe_thread = "Thread subscription at"
comment_subscribe_request = "We received a subscription request to a thread at"
comment_subscribe_never_requested = "If you never visited the site or requested to be notified on thread messages, please ignore this email."
comment_subscribe_click = "Click"
comment_subscribe_here = "HERE"
comment_subscribe_confirm_message = "to confirm your subscription and start receiving notification emails on replies on the thread."
comment_subscribe_unsubscribe_message = "You can unsubscribe all notifications from"
comment_subscribe_unsubscribe_anytime = "at any time using this link"
comment_unsubscribe = "unsubscribe"

View file

@ -162,6 +162,13 @@
<label class="form-check-label"><?php echo i18n('Enable_honeypot');?></label>
</div>
<small class="form-text text-muted"><?php echo i18n('Honeypot_desc');?></small>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="comments.jstime" value="true"
<?php echo comments_config('comments.jstime') === 'true' ? 'checked' : ''; ?>>
<label class="form-check-label"><?php echo i18n('Enable_jstime');?></label>
</div>
<small class="form-text text-muted"><?php echo i18n('Jstime_desc');?></small>
</div>
</div>

View file

@ -3225,6 +3225,7 @@ post('/admin/comments/settings', function () {
// Note: HTML forms convert dots to underscores in POST data
$config['comments.moderation'] = isset($_POST['comments_moderation']) ? 'true' : 'false';
$config['comments.honeypot'] = isset($_POST['comments_honeypot']) ? 'true' : 'false';
$config['comments.jstime'] = isset($_POST['comments_jstime']) ? 'true' : 'false';
$config['comments.notify'] = isset($_POST['comments_notify']) ? 'true' : 'false';
$config['comments.mail.enabled'] = isset($_POST['comments_mail_enabled']) ? 'true' : 'false';
@ -6085,6 +6086,7 @@ post('/comments/submit', function () {
$parentId = from($_POST, 'parent_id');
$notify = from($_POST, 'notify');
$website = from($_POST, 'website'); // honeypot field
$company = from($_POST, 'company'); // antispam js and timestamp field
// Note: $url was also set in json file single comment block, but then it is hard to manage if .md file changes name or path
// introduced instead function get_url_from_file that handle both .md (content) and .json (content/comments)
@ -6095,7 +6097,8 @@ post('/comments/submit', function () {
'comment' => $comment,
'parent_id' => $parentId,
'notify' => $notify,
'website' => $website
'website' => $website,
'company' => $company
);
$result = commentInsert($data, $url, null);

View file

@ -28,6 +28,10 @@ function displayCommentsForm($url, $mdfile = null, $parentId = null)
<input type="text" name="website" tabindex="-1" value="" autocomplete="off">
</div>
<!-- JS check & time check field (hidden from users) -->
<div style="position:absolute;left:-6000px;" aria-hidden="true">
<input type="text" name="company" tabindex="-2" value="" autocomplete="off">
</div>
<div class="form-group" style="width: 100%">
<label for="name-<?php echo $formId; ?>"><?php echo i18n('Name'); ?> <span class="required">*</span></label>
@ -43,14 +47,12 @@ function displayCommentsForm($url, $mdfile = null, $parentId = null)
<textarea class="form-control" id="comment-<?php echo $formId; ?>" name="comment" rows="5" required></textarea>
<small class="form-text text-muted"><?php echo i18n('Comment_formatting_help'); ?></small>
</div>
<!-- Emidio 20251105 - temporarily disabled
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="notify-<?php echo $formId; ?>" name="notify" value="1">
<label class="form-check-label" for="notify-<?php echo $formId; ?>">
<?php echo i18n('Notify_new_comments'); ?>
</label>
</div>
-->
<br>
<div class="form-group">
<button type="submit" class="btn btn-primary submit-comment"><?php echo $parentId ? i18n('Post_Reply') : i18n('Post_Comment'); ?></button>
@ -209,6 +211,9 @@ function displayCommentsSection($url, $file = null)
'<div style="position:absolute;left:-5000px;" aria-hidden="true">' +
'<input type="text" name="website" tabindex="-1" value="" autocomplete="off">' +
'</div>' +
'<div style="position:absolute;left:-6000px;" aria-hidden="true">' +
'<input type="text" name="company" tabindex="-2" value="" autocomplete="off">' +
'</div>' +
'<div class="form-group">' +
'<label for="name-' + formId + '"><?php echo i18n("Name"); ?> <span class="required">*</span></label>' +
'<input type="text" class="form-control" id="name-' + formId + '" name="name" required>' +
@ -223,12 +228,10 @@ function displayCommentsSection($url, $file = null)
'<textarea class="form-control" id="comment-' + formId + '" name="comment" rows="5" required></textarea>' +
'<small class="form-text text-muted"><?php echo i18n("Comment_formatting_help"); ?></small>' +
'</div>' +
'<!-- Emidio 20251105 - temporarily disabled ' +
'<div class="form-group form-check">' +
'<input type="checkbox" class="form-check-input" id="notify-' + formId + '" name="notify" value="1">' +
'<label class="form-check-label" for="notify-' + formId + '"><?php echo i18n("Notify_new_comments"); ?></label>' +
'</div>' +
' -->' +
'<br><div class="form-group">' +
'<button type="submit" class="btn btn-primary submit-reply"><?php echo i18n("Post_Reply"); ?></button> ' +
'<button type="button" class="btn btn-secondary cancel-reply" onclick="cancelReply(\'' + commentId + '\')"><?php echo i18n("Cancel"); ?></button>' +
@ -236,6 +239,13 @@ function displayCommentsSection($url, $file = null)
'</form>';
container.innerHTML = formHtml;
// Populate antispam company field with current timestamp
const timestampSeconds = Math.floor(Date.now() / 1000);
const companyField = container.querySelector('[name="company"]');
if (companyField) {
companyField.value = timestampSeconds;
}
}
}
@ -299,11 +309,24 @@ function displayCommentsSection($url, $file = null)
}
// Antispam protection, executed when page is loaded
document.addEventListener('DOMContentLoaded', function () {
const timestampSeconds = Math.floor(Date.now() / 1000);
// Esegui la funzione quando il DOM è caricato
// Select all forms in page
document.querySelectorAll('form').forEach(function (form) {
// Finds all fields named "company"
form.querySelectorAll('[name="company"]').forEach(function (field) {
field.value = timestampSeconds;
});
});
});
// Executed when page is loaded
document.addEventListener('DOMContentLoaded', handleCommentStatus);
// Esegui anche quando l'hash cambia (se navighi sulla stessa pagina)
// Executed also when page hash changes (navigating in same page)
window.addEventListener('hashchange', handleCommentStatus);
</script>

View file

@ -343,6 +343,26 @@ function buildCommentTree($comments, $parentId = null, $level = 0)
return $tree;
}
/**
* Calculate seconds difference from now
*
* @param int/string $timestamp
* @return difference in seconds
*/
function secondsGenerationSubmit($timestamp) {
if (!is_numeric($timestamp)) {
return null; // invalid value
}
$timestampJS = (int) $timestamp;
$timestampServer = time();
return $timestampServer - $timestampJS;
}
/**
* Validate comment data
*
@ -375,6 +395,13 @@ function validateComment($data)
}
}
// Validate js and time (if enabled) - minimum 2 seconds, maximum 600 seconds
if (comments_config('comments.jstime') === 'true') {
if (!$data['company'] || secondsGenerationSubmit($data['company']) < 3 || secondsGenerationSubmit($data['company']) > 600) {
$errors[] = 'comment_submission_error_spam';
}
}
return array(
'valid' => empty($errors),
'errors' => $errors
@ -442,9 +469,21 @@ function commentInsert($data, $url, $mdfile = null)
'message' => 'comment_submission_error'
);
}
// Subscription handling
if ($comment['notify']) {
setSubscription($comment['email'], 'subscribe');
}
// Send notifications
sendCommentNotifications($url, $comment, $comments);
// Clearing cache if comment is published, otherwise doesn't display on page
if ($comment['published']) {
rebuilt_cache('all');
clear_cache();
}
// Send notifications - notify admin always, notify subscribers only if published
sendCommentNotifications($url, $comment, $comments, true, $comment['published']);
return array(
'success' => true,
@ -453,6 +492,187 @@ function commentInsert($data, $url, $mdfile = null)
);
}
// action can be subscribe, confirm, unsubscribe
function setSubscription($email, $action) {
$subscriptions_dir = 'content/comments/.subscriptions';
$subscription_file = $subscriptions_dir . '/' . encryptEmailForFilename($email, comments_config('comments.salt'));
$subscription = getSubscription($email);
if ($action == 'subscribe') {
if ($subscription['status'] == 'subscribed') {
return true;
}
elseif ($subscription['status'] == 'waiting') {
sendSubscriptionEmail($email);
}
else {
$subscription['status'] = 'waiting';
$json = json_encode($subscription, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
file_put_contents($subscription_file, $json);
sendSubscriptionEmail($email);
return true;
}
}
elseif ($action == 'confirm' && $subscription['status'] == 'waiting') {
$subscription['status'] = 'subscribed';
$json = json_encode($subscription, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
file_put_contents($subscription_file, $json);
return true;
}
elseif ($action == 'unsubscribe') {
@unlink($subscription_file);
return true;
}
else {
// nothing here
return false;
}
}
// returns array
function getSubscription($email) {
$subscriptions_dir = 'content/comments/.subscriptions';
$subscription_file = $subscriptions_dir . '/' . encryptEmailForFilename($email, comments_config('comments.salt'));
if (!file_exists($subscription_file)) {
$subscription['status'] = 'no';
$subscription['date'] = date('Y-m-d H:i:s');
$subscription['email'] = $email;
return $subscription;
}
else {
$subscription = json_decode(file_get_data($subscription_file), true);
return $subscription;
}
}
function confirmSubscription($filename) {
$subscriptions_dir = 'content/comments/.subscriptions';
$subscription_file = $subscriptions_dir . '/' . $filename;
if (sanitizedSubscriptionFile($filename) && file_exists($subscription_file)) {
$subscription = json_decode(file_get_data($subscription_file), true);
setSubscription($subscription['email'], 'confirm');
return true;
}
return false;
}
function sanitizedSubscriptionFile($filename) {
// no path traversal, sanitizing filename
$filename = basename($filename);
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $filename)) {
return false;
}
$subscriptions_dir = 'content/comments/.subscriptions';
$subscription_file = $subscriptions_dir . '/' . $filename;
// check if path is invalid
$real_file = realpath($subscription_file);
$real_dir = realpath($subscriptions_dir);
if ($real_file === false || $real_dir === false) {
return false;
}
// check if path outside .subscriptions dir (we are DELETING files!)
if (strpos($real_file, $real_dir . DIRECTORY_SEPARATOR) !== 0) {
return false;
}
return true;
}
function deleteSubscription($filename) {
$subscriptions_dir = 'content/comments/.subscriptions';
$subscription_file = $subscriptions_dir . '/' . $filename;
if (sanitizedSubscriptionFile($filename) && file_exists($subscription_file)) {
@unlink($subscription_file);
return true;
}
return false;
}
function encryptEmailForFilename(string $email, string $secretKey) {
// Normalize email
$email = strtolower(trim($email));
// Create HMAC hash
$hash = hash_hmac('sha256', $email, $secretKey, true);
// URL-safe Base64 (filename-safe)
$safe = rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
return $safe;
}
function sendSubscriptionEmail($email) {
try {
$mail = new PHPMailer(true);
// Server settings
$mail->isSMTP();
$mail->Host = comments_config('comments.mail.host');
$mail->SMTPAuth = true;
$mail->Username = comments_config('comments.mail.username');
$mail->Password = comments_config('comments.mail.password');
$mail->Port = comments_config('comments.mail.port');
$encryption = comments_config('comments.mail.encryption');
if ($encryption === 'tls') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
} elseif ($encryption === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
}
// Recipients
$mail->setFrom(
comments_config('comments.mail.from.email'),
comments_config('comments.mail.from.name')
);
$mail->addAddress($email);
// Content
$mail->isHTML(true);
$mail->CharSet = 'UTF-8';
$mail->Subject = i18n('comment_subscribe_confirmation') . ' '.config('blog.title');
$mail->Body = "
<h3>" . i18n('comment_subscribe_thread') . ": ".config('site.url')."</h3>
<p>" . i18n('comment_subscribe_request') . " ".config('blog.title')."</p>
<p>" . i18n('comment_subscribe_never_requested') . "</p>
<p>" . i18n('comment_subscribe_click') . " <a href=\"".config('site.url')."?subscribe=".encryptEmailForFilename($email, comments_config('comments.salt'))."\"><b>" . i18n('comment_subscribe_here') . "</b></a> " . i18n('comment_subscribe_confirm_message') . "</p>
<p>&nbsp;</p>
<p>" . i18n('comment_subscribe_unsubscribe_message') . " ".config('blog.title')." " . i18n('comment_subscribe_unsubscribe_anytime') . ": <a href=\"".config('site.url')."?unsubscribe=".encryptEmailForFilename($email, comments_config('comments.salt'))."\"><b>" . i18n('comment_unsubscribe') . "</b></a>.</p>
<p>&nbsp;</p>
";
$mail->send();
return true;
} catch (Exception $e) {
error_log("Subscription notification email failed: {$mail->ErrorInfo}");
return false;
}
}
/**
* Publish a comment (approve from moderation)
*
@ -479,9 +699,11 @@ function commentPublish($file, $commentId)
if ($comment['id'] === $commentId) {
$comment['published'] = true;
$updated = true;
$url = get_url_from_file($file);
// Send notifications to other commenters
sendCommentNotifications($comment, $comments, false);
// Send notifications only to subscribers when publishing (admin already saw it in moderation)
sendCommentNotifications($url, $comment, $comments, false, true);
break;
}
}
@ -491,6 +713,10 @@ function commentPublish($file, $commentId)
}
$json = json_encode($comments, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
rebuilt_cache('all');
clear_cache();
return file_put_contents($file, $json, LOCK_EX) !== false;
}
@ -525,6 +751,10 @@ function commentDelete($mdfile, $commentId)
$comments = array_values($comments);
$json = json_encode($comments, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
rebuilt_cache('all');
clear_cache();
return file_put_contents($file, $json, LOCK_EX) !== false;
}
@ -587,22 +817,24 @@ function commentModify($file, $commentId, $data)
* @param array $newComment The new comment
* @param array $allComments All comments for this post
* @param bool $notifyAdmin Notify admin (default true)
* @param bool $notifySubscribers Notify subscribers (default true)
* @return void
*/
function sendCommentNotifications($url, $newComment, $allComments, $notifyAdmin = true)
function sendCommentNotifications($url, $newComment, $allComments, $notifyAdmin = true, $notifySubscribers = true)
{
// TODO: function to be fixed, still using postId variable
// Check if notifications are enabled
if (comments_config('comments.notify') !== 'true' ||
comments_config('comments.mail.enabled') !== 'true') {
// Check if mail is enabled
if (comments_config('comments.mail.enabled') !== 'true') {
return;
}
$recipients = array();
// Add admin email
// Add admin email - notify if comments.notifyadmin = "true" OR comments.moderation = "true"
if ($notifyAdmin) {
$shouldNotifyAdmin = (comments_config('comments.notifyadmin') === 'true') ||
(comments_config('comments.moderation') === 'true');
if ($shouldNotifyAdmin) {
$adminEmail = comments_config('comments.admin.email');
if (!empty($adminEmail) && filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) {
$recipients[$adminEmail] = array(
@ -611,16 +843,18 @@ function sendCommentNotifications($url, $newComment, $allComments, $notifyAdmin
);
}
}
/*
}
// TODO: this part is disabled until a spam-secured way for comment subscription is implemented
// Add subscribers only if notifySubscribers is true AND comments.notify is enabled
if ($notifySubscribers && comments_config('comments.notify') === 'true') {
// Add parent comment author (if replying)
if (!empty($newComment['parent_id'])) {
foreach ($allComments as $comment) {
if ($comment['id'] === $newComment['parent_id'] &&
$comment['notify'] &&
$comment['email'] !== $newComment['email']) {
$subscrition = getSubscription($comment['email']);
if ($subscrition['status'] == 'subscribed') {
$recipients[$comment['email']] = array(
'name' => $comment['name'],
'type' => 'parent'
@ -628,14 +862,15 @@ function sendCommentNotifications($url, $newComment, $allComments, $notifyAdmin
}
}
}
}
// Add other commenters in same thread who want notifications
// Add all commenters in same thread (same JSON file) who want notifications
foreach ($allComments as $comment) {
if ($comment['notify'] &&
$comment['email'] !== $newComment['email'] &&
$comment['id'] !== $newComment['id']) {
// Same thread = same parent or no parent
if ($comment['parent_id'] === $newComment['parent_id']) {
$subscrition = getSubscription($comment['email']);
if ($subscrition['status'] == 'subscribed') {
$recipients[$comment['email']] = array(
'name' => $comment['name'],
'type' => 'thread'
@ -643,7 +878,8 @@ function sendCommentNotifications($url, $newComment, $allComments, $notifyAdmin
}
}
}
*/
}
// Send emails
foreach ($recipients as $email => $info) {
sendCommentEmail($email, $info['name'], $url, $newComment, $info['type']);
@ -692,22 +928,25 @@ function sendCommentEmail($to, $toName, $url, $comment, $type = 'admin')
$mail->CharSet = 'UTF-8';
if ($type === 'admin') {
$mail->Subject = 'New comment awaiting moderation';
$mail->Subject = i18n('comment_email_admin_subject') . " - " . config('blog.title');
$mail->Body = "
<h3>New comment on: {$url}</h3>
<p><strong>From:</strong> {$comment['name']} ({$comment['email']})</p>
<p><strong>Comment:</strong></p>
<h3>".i18n('comment_email_new').": {$url}</h3>
<p><strong>" . i18n('comment_email_from') . ":</strong> {$comment['name']} ({$comment['email']})</p>
<p><strong>" . i18n('comment') . ":</strong></p>
<p>" . nl2br(htmlspecialchars($comment['comment'])) . "</p>
<p><a href='" . site_url() . "admin/comments'>Moderate comments</a></p>
<p><a href='" . site_url() . "admin/comments'>" . i18n('comment_email_moderate'). "</a></p>
";
} else {
$mail->Subject = 'New reply to your comment';
$mail->Subject = i18n('comment_email_new_subscribed') . " - " . config('blog.title');
$mail->Body = "
<h3>Someone replied to your comment on: {$url}</h3>
<p><strong>From:</strong> {$comment['name']}</p>
<p><strong>Comment:</strong></p>
<h3>" . i18n('comment_email_new_replied') .": " . site_url() . "{$url}</h3>
<p><strong>" . i18n('comment_email_from') . ":</strong> {$comment['name']}</p>
<p><strong>" . i18n('comment') . ":</strong></p>
<p>" . nl2br(htmlspecialchars($comment['comment'])) . "</p>
<p><a href='" . site_url() . "{$url}#comment-{$comment['id']}'>View comment</a></p>
<p><a href='" . site_url() . "{$url}#comment-{$comment['id']}'>" . i18n('comment_email_view_comment') . "</a></p>
<p>&nbsp;</p>
<p>" . i18n('comment_subscribe_unsubscribe_message') . " ".config('blog.title')." " . i18n('comment_subscribe_unsubscribe_anytime') . ": <a href=\"".config('site.url')."?unsubscribe=".encryptEmailForFilename($email, comments_config('comments.salt'))."\"><b>" . i18n('comment_unsubscribe') . "</b></a>.</p>
<p>&nbsp;</p>
";
}
@ -764,4 +1003,17 @@ function formatCommentText($text)
return $text;
}
if (isset($_GET['subscribe'])) {
confirmSubscription($_GET['subscribe']);
}
if (isset($_GET['unsubscribe'])) {
confirmSubscription($_GET['subscribe']);
}
?>

View file

@ -0,0 +1,28 @@
# HTMLy comment system
A commenting system integrated in HTMLy, featuring:
* threaded comments (comments and replies)
* antispam (with no external dependencies, no CAPTCHA)
* notification system and thread subscription
## 2025-12-26
Some major fixes to comment system:
* added English strings in notification emails (needs translations in all other languages)
* improved antispam system
* added subscription verification system
### Antispam
Antispam work using a honeyspot and js/token verification
* honeyspot: field "website" is added as hidden - spambot usually fill it, all comments with this field not empty are discarded as SPAM
* js: javascript must be enabled to have a comment being considered not SPAM - all modern browser have js enabled
* token: a token with encrypted timestamp is generated and added to "company" hidden field - a comment have to be submitted between 3 and 600 seconds from token generation (this should prevent automated submissions (before 3 seconds) and luckily forged tokens (converting in a number, probably resulting in less than 3 or more than 600 seconds difference)
Both methods can be enabled/disabled from comment system configuration page.
## Subscriptions
Users can ask for email notification when a new comment is published in a subscribed post thread. A confirmation email is sent to the user email, and subscription must be confirmed clicking on a link. Only confirmed subscription users will receive notification emails.
Notification email are sent on comment publish (if validation is enabled) or comment insert (if moderation is disabled, not recommended).
**TODO**: limit comment insert by time from same IP address
**TODO**: reworking backend functions to use HTMLy basic functions and avoid code duplication