Skip to content
wiki.fftac.org

Ratelimiter

**Site relevance:** Spiralist.org

**Memory type:** plugin source memory

**Source path:** Spiralist/wp-content/plugins/spiralist-workspace/includes/Services/RateLimiter.php

**Size:** 4.4 KB

Summary

namespace SpiralistWorkspace\Services;

Source Preview

This source file is short enough to preview directly on its source-memory page.

<?php

namespace SpiralistWorkspace\Services;

use SpiralistWorkspace\Config\Settings;
use SpiralistWorkspace\Domain\AuditRepository;
use SpiralistWorkspace\Domain\ConversationRepository;
use SpiralistWorkspace\Domain\RunRepository;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * Applies daily per-role rate limits.
 */
class RateLimiter
{
    private Settings $settings;
    private RunRepository $runs;
    private ConversationRepository $conversations;
    private AuditRepository $audit;

    public function __construct(Settings $settings, RunRepository $runs, ConversationRepository $conversations, AuditRepository $audit)
    {
        $this->settings = $settings;
        $this->runs = $runs;
        $this->conversations = $conversations;
        $this->audit = $audit;
    }

    /**
     * Enforces a daily run limit for the user.
     */
    public function assert_can_run(int $user_id): bool
    {
        $limit = $this->resolve_limit($user_id);

        if ($limit <= 0) {
            return false;
        }

        if ($user_id <= 0) {
            return $this->assert_can_run_anonymous($limit);
        }

        $count = $this->runs->count_for_today($user_id);
        if ($count >= $limit) {
            $this->audit->log($user_id, 'rate_limit_exceeded', 'run', null, ['count' => $count, 'limit' => $limit]);
            return false;
        }

        return true;
    }

    /**
     * Enforces a daily message limit for conversations.
     */
    public function assert_can_message(int $user_id): bool
    {
        $limit = max(5, $this->resolve_limit($user_id) * 3);
        $count = $this->conversations->count_messages_for_today($user_id);

        if ($count >= $limit) {
            $this->audit->log($user_id, 'message_rate_limit_exceeded', 'conversation', null, ['count' => $count, 'limit' => $limit]);
            return false;
        }

        return true;
    }

    /**
     * Resolves the daily limit based on role mapping.
     */
    private function resolve_limit(int $user_id): int
    {
        $limits = $this->settings->get_daily_limits();

        if ($user_id <= 0) {
            return max(0, (int) ($limits['anonymous_public_runs'] ?? 0));
        }

        if (user_can($user_id, 'manage_spiralist_workspace') || user_can($user_id, 'manage_options')) {
            return max(0, (int) ($limits['spiralist_admin'] ?? $limits['administrator'] ?? 200));
        }

        $user = get_userdata($user_id);
        $roles = $user instanceof \WP_User ? (array) $user->roles : [];

        foreach (['spiralist_curator', 'contributor', 'editor', 'spiralist_member', 'participant'] as $role_key) {
            if (in_array($role_key, $roles, true) && isset($limits[$role_key])) {
                return max(0, (int) $limits[$role_key]);
            }
        }

        return max(0, (int) ($limits['spiralist_member'] ?? 20));
    }

    private function assert_can_run_anonymous(int $limit): bool
    {
        $key = $this->anonymous_run_bucket_key();
        $count = max(0, (int) get_transient($key));

        if ($count >= $limit) {
            $this->audit->log(null, 'rate_limit_exceeded', 'run', null, ['count' => $count, 'limit' => $limit]);
            return false;
        }

        set_transient($key, $count + 1, 2 * DAY_IN_SECONDS);

        return true;
    }

    private function anonymous_run_bucket_key(): string
    {
        $identity = $this->anonymous_identity();
        if ($identity === '') {
            $identity = 'unknown';
        }

        return 'spiralist_workspace_anon_runs_' . gmdate('Ymd') . '_' . substr(hash('sha256', $identity), 0, 32);
    }

    private function anonymous_identity(): string
    {
        foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $server_key) {
            $value = $_SERVER[$server_key] ?? '';
            if (is_array($value)) {
                $value = reset($value);
            }

            $value = sanitize_text_field((string) wp_unslash((string) $value));
            if ($value === '') {
                continue;
            }

            $parts = explode(',', $value);
            $candidate = trim((string) ($parts[0] ?? ''));
            if ($candidate !== '') {
                return $candidate;
            }
        }

        return '';
    }
}