• File: comments-20261026221202.php
  • Full Path: /home/daemondevin/public_html/portableapps/theme/portableapps/comments-20261026221202.php
  • Date Modified: 10/23/2026 3:45 AM
  • File size: 15.2 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php
defined('TS_ROOT') or exit;
// Lite comments. One private data file (data/comments.php) holds every comment, whichever way posts are stored.
//
// Modes (setting "comments"): off (nothing shown), moderate (new comments wait for approval), open (shown at once),
// closed (existing comments stay, no new ones). Spam defenses: a form token tied to the post, a hidden trap
// field, a per visitor rate limit, a link limit, and duplicate detection. Commenters never get an account and
// their email address is never shown.

final class TSComments
{
    public const MAX_BODY = 2000;
    public const MAX_LINKS = 2;
    public const STATUSES = ['pending', 'approved', 'spam'];

    private static ?array $memo = null;

    public static function mode(): string
    {
        $m = (string) ts_cfg('comments', 'off');
        return in_array($m, ['moderate', 'open', 'closed'], true) ? $m : 'off';
    }

    public static function enabled(): bool { return self::mode() !== 'off'; }
    public static function accepting(): bool { return in_array(self::mode(), ['moderate', 'open'], true); }

    // Storage

    private static function load(): array
    {
        if (self::$memo !== null) return self::$memo;
        $d = ts_data_read('comments');
        return self::$memo = ['next' => (int) ($d['next'] ?? 1), 'items' => array_values((array) ($d['items'] ?? [])), 'hits' => (array) ($d['hits'] ?? [])];
    }

    // Read, change and write under a lock so two visitors commenting at once do not lose each other's text
    private static function mutate(callable $fn)
    {
        $lock = @fopen(ts_data_dir() . '/comments.lock', 'c');
        if ($lock) @flock($lock, LOCK_EX);
        self::$memo = null;
        $d = self::load();
        $result = $fn($d);
        self::$memo = $d;
        $ok = ts_data_write('comments', $d);
        if ($lock) { @flock($lock, LOCK_UN); fclose($lock); }
        if (!$ok) throw new RuntimeException('The blog could not save the comment because the “data” folder is not writable.');
        return $result;
    }

    public static function all(): array
    {
        $items = self::load()['items'];
        usort($items, fn($a, $b) => strcmp($b['date'], $a['date']) ?: $b['id'] <=> $a['id']);
        return $items;
    }

    public static function counts(): array
    {
        $c = [];
        foreach (self::load()['items'] as $it) if ($it['status'] === 'approved') $c[$it['slug']] = ($c[$it['slug']] ?? 0) + 1;
        return $c;
    }

    public static function count(string $slug): int { return self::counts()[$slug] ?? 0; }

    public static function pending(): int
    {
        $n = 0;
        foreach (self::load()['items'] as $it) if ($it['status'] === 'pending') $n++;
        return $n;
    }

    // Approved comments of a post, oldest first. Replies sit under their parent, one level deep.
    public static function thread(string $slug): array
    {
        $top = [];
        $kids = [];
        $byId = [];
        foreach (self::load()['items'] as $it) {
            if ($it['slug'] !== $slug || $it['status'] !== 'approved') continue;
            $byId[$it['id']] = $it;
        }
        foreach ($byId as $it) {
            $p = (int) ($it['parent'] ?? 0);
            if ($p && isset($byId[$p])) {
                while (!empty($byId[$p]['parent']) && isset($byId[(int) $byId[$p]['parent']])) $p = (int) $byId[$p]['parent'];
                $kids[$p][] = $it;
            } else {
                $top[] = $it;
            }
        }
        foreach ($top as &$t) $t['replies'] = $kids[$t['id']] ?? [];
        unset($t);
        return $top;
    }

    public static function find(int $id): ?array
    {
        foreach (self::load()['items'] as $it) if ($it['id'] === $id) return $it;
        return null;
    }

    // Admin actions

    public static function setStatus(int $id, string $status): bool
    {
        if (!in_array($status, self::STATUSES, true)) return false;
        return (bool) self::mutate(function (&$d) use ($id, $status) {
            foreach ($d['items'] as &$it) if ($it['id'] === $id) { $it['status'] = $status; return true; }
            return false;
        });
    }

    public static function delete(int $id): bool
    {
        return (bool) self::mutate(function (&$d) use ($id) {
            $n = count($d['items']);
            // A reply whose parent is deleted becomes a top level comment
            foreach ($d['items'] as &$it) if (($it['parent'] ?? 0) === $id) $it['parent'] = 0;
            unset($it);
            $d['items'] = array_values(array_filter($d['items'], fn($it) => $it['id'] !== $id));
            return count($d['items']) < $n;
        });
    }

    public static function deleteForSlug(string $slug): void
    {
        self::mutate(function (&$d) use ($slug) { $d['items'] = array_values(array_filter($d['items'], fn($it) => $it['slug'] !== $slug)); });
    }

    public static function renameSlug(string $old, string $new): void
    {
        if ($old === $new) return;
        self::mutate(function (&$d) use ($old, $new) { foreach ($d['items'] as &$it) if ($it['slug'] === $old) $it['slug'] = $new; });
    }

    // Form token: proves the form was loaded from this site recently. Changes once an hour, so pages stay cacheable.
    private static function secret(): string
    {
        $s = (string) (ts_data_read('state')['comment_secret'] ?? '');
        if ($s === '') { $s = bin2hex(random_bytes(16)); ts_state_set(['comment_secret' => $s]); }
        return $s;
    }

    public static function token(string $slug): string
    {
        return hash_hmac('sha256', $slug . '|' . intdiv(time(), 3600), self::secret());
    }

    private static function tokenOk(string $slug, string $t): bool
    {
        for ($i = 0; $i <= 24; $i++) {
            if (hash_equals(hash_hmac('sha256', $slug . '|' . (intdiv(time(), 3600) - $i), self::secret()), $t)) return true;
        }
        return false;
    }

    private static function visitor(): string
    {
        return substr(hash_hmac('sha256', (string) ($_SERVER['REMOTE_ADDR'] ?? '0'), self::secret()), 0, 16);
    }

    public static function messages(): array
    {
        return [
            'ok' => 'Thanks! Your comment is published.',
            'pending' => 'Thanks! Your comment is waiting for approval and will appear once it has been checked.',
            'name' => 'Please enter your name.',
            'email' => 'That email address does not look right. You can leave it empty.',
            'short' => 'Please write a little more.',
            'long' => 'That comment is too long. The limit is ' . self::MAX_BODY . ' characters.',
            'token' => 'The form expired. Please reload the page and try again.',
            'throttle' => 'You are commenting very quickly. Please wait a few minutes and try again.',
            'closed' => 'Comments are closed.',
            'dupe' => 'You already posted that comment.',
            'save' => 'The comment could not be saved. Please try again later.',
            'post' => 'That post does not exist.',
        ];
    }

    // Validates and stores a comment from a form. Returns ['code' => message key, 'comment' => array|null].
    public static function submit(string $slug, array $in, TSStore $store): array
    {
        if (!self::accepting()) return ['code' => 'closed', 'comment' => null];
        $post = $store->get($slug);
        if (!$post || $post['status'] !== 'published' || $post['type'] !== 'post' || strtotime($post['date']) > time()) return ['code' => 'post', 'comment' => null];
        // The hidden trap field: a person never fills it in. Pretend it worked so bots learn nothing.
        if (trim((string) ($in['website'] ?? '')) !== '') return ['code' => 'pending', 'comment' => null];
        if (!self::tokenOk($slug, (string) ($in['token'] ?? ''))) return ['code' => 'token', 'comment' => null];

        $name = trim(preg_replace('/\s+/', ' ', strip_tags((string) ($in['name'] ?? ''))));
        $email = trim((string) ($in['email'] ?? ''));
        $body = trim(str_replace(["\r\n", "\r"], "\n", (string) ($in['body'] ?? '')));
        $body = preg_replace("/\n{3,}/", "\n\n", $body);
        if ($name === '' || mb_strlen($name) > 60) return ['code' => 'name', 'comment' => null];
        if ($email !== '' && (mb_strlen($email) > 120 || !filter_var($email, FILTER_VALIDATE_EMAIL))) return ['code' => 'email', 'comment' => null];
        if (mb_strlen($body) < 2) return ['code' => 'short', 'comment' => null];
        if (mb_strlen($body) > self::MAX_BODY) return ['code' => 'long', 'comment' => null];

        $who = self::visitor();
        $links = preg_match_all('#https?://|www\.#i', $body);
        $status = self::mode() === 'open' && $links <= self::MAX_LINKS ? 'approved' : 'pending';
        $parent = (int) ($in['parent'] ?? 0);

        return self::mutate(function (&$d) use ($slug, $name, $email, $body, $status, $parent, $who) {
            $now = time();
            // At most 5 comments per visitor in 10 minutes and 30 a day
            $hits = array_values(array_filter($d['hits'][$who] ?? [], fn($t) => $t > $now - 86400));
            $recent = count(array_filter($hits, fn($t) => $t > $now - 600));
            if ($recent >= 5 || count($hits) >= 30) return ['code' => 'throttle', 'comment' => null];
            foreach ($d['items'] as $it) if ($it['slug'] === $slug && $it['name'] === $name && $it['body'] === $body) return ['code' => 'dupe', 'comment' => null];
            // Forget visitors with no recent activity so the file stays small
            foreach ($d['hits'] as $k => $v) if (!array_filter($v, fn($t) => $t > $now - 86400)) unset($d['hits'][$k]);
            $hits[] = $now;
            $d['hits'][$who] = $hits;
            if ($parent) {
                $ok = false;
                foreach ($d['items'] as $it) if ($it['id'] === $parent && $it['slug'] === $slug && $it['status'] === 'approved') $ok = true;
                if (!$ok) $parent = 0;
            }
            $c = ['id' => $d['next']++, 'slug' => $slug, 'parent' => $parent, 'name' => $name, 'email' => $email,
                'body' => $body, 'date' => date('Y-m-d H:i'), 'status' => $status];
            $d['items'][] = $c;
            return ['code' => $status === 'approved' ? 'ok' : 'pending', 'comment' => $c];
        });
    }

    // Comment text to HTML: escaped, paragraphs and line breaks, web addresses become links nobody gets ranking from
    public static function html(string $body): string
    {
        $out = [];
        foreach (preg_split("/\n{2,}/", $body) as $para) {
            $t = htmlspecialchars(trim($para), ENT_NOQUOTES, 'UTF-8');
            $t = preg_replace_callback('#\bhttps?://[^\s<]+[^\s<.,;:!?)]#i', fn($m) =>
                '<a href="' . htmlspecialchars(html_entity_decode($m[0]), ENT_QUOTES) . '" rel="nofollow ugc noopener">' . $m[0] . '</a>', $t);
            $out[] = '<p>' . nl2br($t, false) . '</p>';
        }
        return implode("\n", $out);
    }
}

// The comments section of a post: list, then form. Themes style the tsc-* classes. Returns '' when comments are off.
function ts_comments_section(array $post): string
{
    if (!TSComments::enabled() || ($post['type'] ?? 'post') !== 'post') return '';
    $thread = TSComments::thread($post['slug']);
    $total = TSComments::count($post['slug']);
    $msgs = TSComments::messages();
    $flash = (string) ($_GET['c'] ?? '');
    $flashOk = in_array($flash, ['ok', 'pending'], true);
    $reply = (int) ($_GET['reply'] ?? 0);
    $replyTo = $reply ? TSComments::find($reply) : null;
    if ($replyTo && ($replyTo['slug'] !== $post['slug'] || $replyTo['status'] !== 'approved')) $replyTo = null;

    $item = function (array $c, bool $isReply) use (&$item, $post) {
        $initial = mb_strtoupper(mb_substr($c['name'], 0, 1));
        $hue = hexdec(substr(md5(mb_strtolower($c['name'])), 0, 2)) % 6;
        $o = '<li class="tsc-item' . ($isReply ? ' tsc-reply' : '') . '" id="comment-' . (int) $c['id'] . '">'
            . '<span class="tsc-av" data-c="' . $hue . '" aria-hidden="true">' . e($initial) . '</span>'
            . '<div class="tsc-main"><div class="tsc-head"><strong class="tsc-name">' . e($c['name']) . '</strong>'
            . '<time datetime="' . e(date('c', strtotime($c['date']))) . '">' . e(ts_date($c['date'], 'M j, Y')) . '</time></div>'
            . '<div class="tsc-body">' . TSComments::html($c['body']) . '</div>';
        if (TSComments::accepting() && !$isReply) {
            $o .= '<a class="tsc-reply-link" href="' . e(ts_url($post['slug'], ['reply' => $c['id']])) . '#respond" data-reply="' . (int) $c['id'] . '" data-name="' . e($c['name']) . '">Reply</a>';
        }
        $o .= '</div>';
        if (!empty($c['replies'])) {
            $o .= '<ol class="tsc-list tsc-replies">';
            foreach ($c['replies'] as $r) $o .= $item($r, true);
            $o .= '</ol>';
        }
        return $o . '</li>';
    };

    $h = '<section id="comments" class="tsc" aria-labelledby="comments-title"><h2 id="comments-title" class="tsc-title">' . ($total === 1 ? '1 Comment' : $total . ' Comments') . '</h2>';
    $h .= '<div id="comment-flash" class="tsc-flash" role="status" aria-live="polite">';
    if ($flashOk) $h .= '<p class="alert alert-success">' . e($msgs[$flash]) . '</p>';
    elseif ($flash !== '' && isset($msgs[$flash])) $h .= '<p class="alert alert-danger">' . e($msgs[$flash]) . '</p>';
    $h .= '</div>';
    $h .= $thread ? '<ol class="tsc-list">' . implode('', array_map(fn($c) => $item($c, false), $thread)) . '</ol>' : '<p class="tsc-none">No comments yet.' . (TSComments::accepting() ? ' Be the first.' : '') . '</p>';

    if (TSComments::accepting()) {
        $h .= '<form id="respond" class="tsc-form" method="post" action="' . e(ts_url('comment')) . '" data-endpoint="' . e(ts_url('comment')) . '">'
            . '<h3 class="tsc-sub">Leave a comment</h3>'
            . '<p class="tsc-replying"' . ($replyTo ? '' : ' hidden') . '><span>Replying to <strong id="tsc-replying-name">' . e($replyTo['name'] ?? '') . '</strong></span> <a href="' . e(ts_url($post['slug'])) . '#respond" id="tsc-cancel">Cancel</a></p>'
            . '<input type="hidden" name="post" value="' . e($post['slug']) . '">'
            . '<input type="hidden" name="token" value="' . e(TSComments::token($post['slug'])) . '">'
            . '<input type="hidden" name="parent" value="' . (int) ($replyTo['id'] ?? 0) . '">'
            . '<div class="tsc-trap" aria-hidden="true"><label>Leave this empty<input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>'
            . '<div class="tsc-row"><label class="tsc-field">Name <span class="tsc-req">*</span><input type="text" name="name" maxlength="60" required autocomplete="name"></label>'
            . '<label class="tsc-field">Email <small>(optional, never shown)</small><input type="email" name="email" maxlength="120" autocomplete="email"></label></div>'
            . '<label class="tsc-field">Comment <span class="tsc-req">*</span><textarea name="body" rows="5" maxlength="' . TSComments::MAX_BODY . '" required></textarea></label>'
            . '<p class="tsc-note">' . (TSComments::mode() === 'moderate' ? 'Comments are checked before they appear.' : 'Plain text only. Web addresses become links.') . '</p>'
            . '<button class="tsc-submit" type="submit">Post comment</button></form>';
    } else {
        $h .= '<p class="tsc-closed">Comments are closed.</p>';
    }
    $h .= '</section>';
    return $h;
}