Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# 0.4.3-dev

- Serialize moderation, deletion and retention with a stable storage lock.
- Remove hidden pending residues of interrupted approvals during retention, including dry-run reporting.
- Write complete JSON to unique temporary files before atomic replacement; keep previous data on failure.
- Preserve published edits when recovering an interrupted approval and prevent deleted comments from resurfacing.
- Keep rate-limit counters intact on interrupted writes; reject empty or damaged existing files.
- Register daily comment retention with the Grav scheduler and support a non-mutating pruning preview.
- Add multiprocess and storage-failure regression tests.

# 0.4.2
## 2026-09-23

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,28 @@ only the IP or only the email does not bypass the limit. The rate-limit file is
stored under the site's `user-data://void-comments/` directory and contains only
SHA-256-derived keys and timestamps. Files written by version 0.1.x, which used
an IP+email pair key, remain effective for that exact pair during the migration.

## Storage and retention

Moderation operations use a stable `.storage.lock` file. Rate limits use a
separate lock beside the JSON counters. Do not remove these locks while PHP
workers are running. Writes use unique temporary files, check complete writes
and flush before replacing JSON. Use local storage with working `flock` and
atomic rename semantics; distributed filesystems need separate validation.

The approved file is the commit point for approval. Retrying an interrupted
approval removes stale pending data without replacing subsequent approved edits.
Readers suppress the stale pending copy; retention removes it without changing
the approved record, and includes the cleanup in pruning previews. Deleting an approved comment also
removes any pending copy left by an interrupted approval.

The `void-comments-retention` scheduler job runs daily at 03:20 in Grav's
scheduler timezone. Configure the host to invoke Grav's scheduler regularly;
registering the job alone does not install an operating-system cron task.
It uses the existing policy: technical metadata expires after 7 days and
pending comments after 90 days. Approved comments have no automatic expiry.
`CommentStore::prune($now, true)` previews removals without changing records.
The same storage lock serializes retention and moderation.

`composer test` includes worker-process concurrency, short-write, failed-write,
approval-recovery and retention-preview checks. No site records are required.
2 changes: 1 addition & 1 deletion blueprints.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Void Comments
slug: void-comments
type: plugin
version: 0.4.2
version: 0.4.3-dev
description: Shared flat-file comments for Grav 2 with moderation, replies, rate limiting and CAPTCHA.
icon: comments
author:
Expand Down
12 changes: 10 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
"name": "voidlabs/grav-plugin-void-comments",
"type": "grav-plugin",
"description": "Shared flat-file comments for Grav 2 with moderation, replies and rate limiting.",
"keywords": ["grav", "plugin", "comments", "moderation"],
"keywords": [
"grav",
"plugin",
"comments",
"moderation"
],
"homepage": "https://github.com/voidlabs/grav-plugin-void-comments",
"license": "MIT",
"authors": [
Expand All @@ -27,6 +32,9 @@
}
},
"scripts": {
"test": "@php tests/run.php"
"test": [
"@php tests/run.php",
"@php tests/storage.php"
]
}
}
62 changes: 62 additions & 0 deletions src/AtomicJson.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace VoidLabs\Comments;

/** Stable lock files must never be unlinked while workers can be running. */
final class AtomicJson
{
public static function locked(string $file, callable $operation): mixed
{
self::directory(dirname($file));
$handle = fopen($file, 'c+b');
if ($handle === false) throw new \RuntimeException('Cannot open storage lock.');
try {
if (!flock($handle, LOCK_EX)) throw new \RuntimeException('Cannot lock storage.');
return $operation();
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}

public static function read(string $file): array
{
if (!is_file($file)) return [];
$raw = file_get_contents($file);
if ($raw === false || trim($raw) === '') throw new \RuntimeException('Empty or unreadable JSON storage.');
$data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) throw new \RuntimeException('Invalid JSON storage.');
return $data;
}

public static function write(string $file, array $data): void
{
self::directory(dirname($file));
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
$temporary = $file . '.tmp-' . bin2hex(random_bytes(12));
$handle = fopen($temporary, 'xb');
if ($handle === false) throw new \RuntimeException('Cannot create temporary storage.');
try {
chmod($temporary, 0600);
for ($offset = 0, $length = strlen($json); $offset < $length; $offset += $written) {
$written = fwrite($handle, substr($json, $offset));
if ($written === false || $written === 0) throw new \RuntimeException('Incomplete storage write.');
}
if (!fflush($handle) || !fsync($handle)) throw new \RuntimeException('Cannot flush storage.');
fclose($handle);
$handle = null;
// Keep the old file intact if replacement fails (including on Windows).
if (!rename($temporary, $file)) throw new \RuntimeException('Cannot replace storage.');
} finally {
if (is_resource($handle)) fclose($handle);
if (is_file($temporary)) unlink($temporary);
}
}

private static function directory(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
throw new \RuntimeException('Cannot create storage directory.');
}
}
}
65 changes: 29 additions & 36 deletions src/CommentRateLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,43 @@

namespace VoidLabs\Comments;

require_once __DIR__ . '/AtomicJson.php';

final class CommentRateLimiter
{
public function __construct(private readonly string $file, private readonly int $window = 3600, private readonly int $maximum = 3) {}

public function consume(string $address, string $email, ?int $now = null): bool
{
$now ??= time();
$directory = dirname($this->file);
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) return false;
$handle = fopen($this->file, 'c+');
if ($handle === false || !flock($handle, LOCK_EX)) {
if (is_resource($handle)) fclose($handle);
return false;
}
try {
$raw = stream_get_contents($handle);
$data = $raw === '' ? [] : json_decode((string) $raw, true);
if (!is_array($data)) return false;
$address = strtolower(trim($address));
$email = strtolower(trim($email));
$legacyKey = hash('sha256', $address . "\0" . $email);
$keys = ['ip:' . hash('sha256', $address), 'email:' . hash('sha256', $email)];
$threshold = $now - $this->window;
foreach ($data as $candidate => $times) {
$data[$candidate] = array_values(array_filter(
(array) $times,
static fn($time): bool => is_int($time) && $time > $threshold
));
if ($data[$candidate] === []) unset($data[$candidate]);
}
// Files created by 0.1.x used a hash of IP + email. Keep active
// entries for that exact pair effective while new requests use
// independent IP and email counters.
foreach ($keys as $key) {
if (count($data[$legacyKey] ?? []) + count($data[$key] ?? []) >= $this->maximum) return false;
}
foreach ($keys as $key) $data[$key][] = $now;
$json = json_encode($data, JSON_THROW_ON_ERROR);
rewind($handle);
return ftruncate($handle, 0)
&& fwrite($handle, $json) === strlen($json)
&& fflush($handle);
} finally {
flock($handle, LOCK_UN); fclose($handle);
return AtomicJson::locked($this->file . '.lock', function () use ($address, $email, $now): bool {
$data = AtomicJson::read($this->file);
$address = strtolower(trim($address));
$email = strtolower(trim($email));
$legacyKey = hash('sha256', $address . "\0" . $email);
$keys = ['ip:' . hash('sha256', $address), 'email:' . hash('sha256', $email)];
$threshold = $now - $this->window;
foreach ($data as $candidate => $times) {
$data[$candidate] = array_values(array_filter(
(array) $times,
static fn($time): bool => is_int($time) && $time > $threshold
));
if ($data[$candidate] === []) unset($data[$candidate]);
}
// Files created by 0.1.x used a hash of IP + email. Keep active
// entries for that exact pair effective while new requests use
// independent IP and email counters.
foreach ($keys as $key) {
if (count($data[$legacyKey] ?? []) + count($data[$key] ?? []) >= $this->maximum) return false;
}
foreach ($keys as $key) $data[$key][] = $now;
AtomicJson::write($this->file, $data);
return true;
});
} catch (\Throwable) {
// Missing files start new counters; damaged existing files fail closed.
return false;
}
}
}
76 changes: 66 additions & 10 deletions src/CommentStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,29 @@

namespace VoidLabs\Comments;

require_once __DIR__ . '/AtomicJson.php';

final class CommentStore
{
private bool $locked = false;

private function transaction(callable $operation): mixed
{
if ($this->locked) return $operation();
return AtomicJson::locked($this->directory . '/.storage.lock', function () use ($operation) {
$this->locked = true;
try { return $operation(); } finally { $this->locked = false; }
});
}

public function __construct(private readonly string $directory) {}

public function addPending(CommentRequest $request, string $address, ?\DateTimeImmutable $now = null): array
{
return $this->transaction(fn() => $this->addPendingLocked($request, $address, $now));
}

private function addPendingLocked(CommentRequest $request, string $address, ?\DateTimeImmutable $now = null): array
{
$now ??= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$id = bin2hex(random_bytes(16));
Expand Down Expand Up @@ -125,33 +143,58 @@ public function approvedParentForRoute(string $id, string $route): ?array
}

public function approve(string $id, ?\DateTimeImmutable $now = null): bool
{
return $this->transaction(fn() => $this->approveLocked($id, $now));
}

private function approveLocked(string $id, ?\DateTimeImmutable $now = null): bool
{
$record = $this->read('pending', $id);
if ($record === null) return false;
$now ??= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$record['moderated_at'] = $now->format(\DateTimeInterface::ATOM); unset($record['technical']);
$this->write('approved', $id, $record);
// Recover a previous approval interrupted after publishing the approved record.
// Never overwrite subsequent moderator edits with the stale pending copy.
if ($this->read('approved', $id) === null) $this->write('approved', $id, $record);
$source = $this->filename('pending', $id);
if (is_file($source) && !unlink($source)) throw new \RuntimeException('Impossibile rimuovere il commento pending.');
return true;
}

public function deletePending(string $id): bool
{
return $this->transaction(fn() => $this->deletePendingLocked($id));
}

private function deletePendingLocked(string $id): bool
{
$filename = $this->filename('pending', $id);
return is_file($filename) && unlink($filename);
}

public function deleteApproved(string $id): bool
{
return $this->transaction(fn() => $this->deleteApprovedLocked($id));
}

private function deleteApprovedLocked(string $id): bool
{
$filename = $this->filename('approved', $id);
$pending = $this->filename('pending', $id);
if (is_file($filename) && is_file($pending) && !unlink($pending)) throw new \RuntimeException('Cannot remove interrupted approval.');
return is_file($filename) && unlink($filename);
}

public function update(string $status, string $id, array $changes): bool
{
return $this->transaction(fn() => $this->updateLocked($status, $id, $changes));
}

private function updateLocked(string $status, string $id, array $changes): bool
{
if (!in_array($status, ['pending', 'approved'], true)) throw new \InvalidArgumentException('Stato commento non valido.');
$record = $this->read($status, $id);
if ($status === 'pending' && is_file($this->filename('approved', $id))) return false;
if ($record === null) return false;
if (array_key_exists('author', $changes)) {
$author = trim((string) $changes['author']);
Expand All @@ -178,31 +221,48 @@ public function update(string $status, string $id, array $changes): bool
return true;
}

public function prune(?\DateTimeImmutable $now = null): array
public function prune(?\DateTimeImmutable $now = null, bool $dryRun = false): array
{
return $this->transaction(fn() => $this->pruneLocked($now, $dryRun));
}

private function pruneLocked(?\DateTimeImmutable $now = null, bool $dryRun = false): array
{
$now ??= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$result = ['technical_removed' => 0, 'pending_deleted' => 0];
foreach ($this->records('pending') as $record) {
foreach ($this->recordsLocked('pending', true) as $record) {
$id = (string) ($record['id'] ?? '');
// Readers hide these records; retention must still remove their private data.
if ($this->read('approved', $id) !== null) {
if ($dryRun || $this->deletePendingLocked($id)) ++$result['pending_deleted'];
continue;
}
$created = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, (string) ($record['created_at'] ?? ''));
if ($created && $created <= $now->modify('-90 days')) {
if ($this->deletePending($id)) ++$result['pending_deleted'];
if ($dryRun || $this->deletePending($id)) ++$result['pending_deleted'];
continue;
}
$expires = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, (string) ($record['technical']['expires_at'] ?? ''));
if (isset($record['technical']) && $expires && $expires <= $now) {
unset($record['technical']); $this->write('pending', $id, $record); ++$result['technical_removed'];
unset($record['technical']); if (!$dryRun) $this->write('pending', $id, $record); ++$result['technical_removed'];
}
}
return $result;
}

private function records(string $status): array
{
return $this->transaction(fn() => $this->recordsLocked($status));
}

private function recordsLocked(string $status, bool $includeSuperseded = false): array
{
$directory = $this->directory . '/' . $status;
if (!is_dir($directory)) return [];
$records = [];
foreach (glob($directory . '/*.json') ?: [] as $filename) {
// Publishing the approved file is the commit point, even after a crash.
if (!$includeSuperseded && $status === 'pending' && is_file($this->directory . '/approved/' . basename($filename))) continue;
$record = json_decode((string) file_get_contents($filename), true);
if (is_array($record)) {
// La directory e' l'unica fonte dello stato. I record legacy
Expand Down Expand Up @@ -230,11 +290,7 @@ private function write(string $status, string $id, array $record): void
unset($record['status']);
$directory = $this->directory . '/' . $status;
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) throw new \RuntimeException('Impossibile creare lo storage dei commenti.');
$target = $this->filename($status, $id); $temporary = $target . '.tmp-' . bin2hex(random_bytes(4));
$json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
if (file_put_contents($temporary, $json, LOCK_EX) === false || !rename($temporary, $target)) {
@unlink($temporary); throw new \RuntimeException('Impossibile aggiornare il commento.');
}
AtomicJson::write($this->filename($status, $id), $record);
}

private function filename(string $status, string $id): string
Expand Down
2 changes: 1 addition & 1 deletion tests/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function remove_tree(string $directory): void
if (!is_dir($directory)) {
return;
}
foreach (glob($directory . '/*') ?: [] as $path) {
foreach (array_merge(glob($directory . '/*') ?: [], glob($directory . '/.[!.]*') ?: []) as $path) {
if (is_dir($path)) {
remove_tree($path);
} else {
Expand Down
Loading
Loading