turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

ENG-005

A shoutbox that does not become a spam channel

Rate limited, length capped, link stripped, moderatable, and cheap to poll. The five constraints that keep a chat box usable.

A shoutbox makes a faucet feel alive and, unmoderated, becomes a referral-link dumping ground within a day. Every constraint below exists because of a specific way that happens.

Rate limiting stops flooding. A length cap stops walls of text. Stripping links stops the referral spam that is the entire reason most people post. A minimum account age stops throwaway accounts. And soft deletion keeps the evidence when you remove something.

Polling is the other half. A chat box refreshing the whole page every ten seconds is a self-inflicted load problem; return only messages newer than the last id the client has.

PHP
function post_shout(PDO $pdo, int $userId, string $message): array
{
    $message = trim(preg_replace('~\s+~', ' ', $message) ?? '');

    if ($message === '' || mb_strlen($message) > 200) {
        return ['ok' => false, 'error' => 'Messages are between 1 and 200 characters.'];
    }
    if (!rate_hit($pdo, 'shout:' . $userId, 5, 60)) {
        return ['ok' => false, 'error' => 'Slow down a little.'];
    }

    $st = $pdo->prepare('SELECT created_at, status FROM users WHERE id = ?');
    $st->execute([$userId]);
    $u = $st->fetch(PDO::FETCH_ASSOC);
    if (!$u || (int) $u['status'] !== 1 || strtotime((string) $u['created_at']) > time() - 86400) {
        return ['ok' => false, 'error' => 'New accounts can post after a day.'];
    }

    // Links are what the box gets used for if you let it.
    if (preg_match('~(https?://|www\.|\.(com|net|org|io|xyz|club)\b)~i', $message)) {
        return ['ok' => false, 'error' => 'Links are not allowed here.'];
    }

    $pdo->prepare('INSERT INTO shouts (user_id, message, created_at) VALUES (?, ?, NOW())')
        ->execute([$userId, $message]);

    return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}

// Poll cheaply: only what the client has not seen.
function shouts_since(PDO $pdo, int $sinceId, int $limit = 40): array
{
    $st = $pdo->prepare(
        'SELECT s.id, s.message, s.created_at, u.username
         FROM shouts s JOIN users u ON u.id = s.user_id
         WHERE s.id > ? AND s.deleted_at IS NULL
         ORDER BY s.id DESC LIMIT ' . (int) $limit
    );
    $st->execute([$sinceId]);
    return array_reverse($st->fetchAll(PDO::FETCH_ASSOC));
}

Using it

Escape on output, always. The message is user input going into markup and this is the most obvious injection point on the whole site.

Soft delete with a deleted_at column so a moderator decision can be reviewed and reversed.

Poll no more than every ten seconds, and only for new ids. The index on the primary key makes that query almost free.

What bites people

A link filter catches the obvious attempts; people will write domains with spaces. Combine it with moderation rather than expecting the regex to win.

Rate limit by account and by address. One user with five accounts is the case a per-account limit misses.

Never render messages with HTML enabled, however tempting emoji formatting looks.

Also in Engagement and Content