-" in the existing budgets table. The token is a keyed HMAC of the * client (account, or the connecting address with IPv6 reduced to its /64) under a key that changes every day, so a bucket name * cannot be turned back into an address and cannot link a client across days. Buckets are created lazily and removed by the * retention job (Ledger::purgeClientBuckets). Windows are fixed UTC days, not sliding: a rolling window would need a row per * request or a schema change; the cost of the fixed window is that a client can spend up to twice the daily amount across a * midnight boundary. * * Three kinds of client share the mechanism: * client one account or one network address: its own allowance. * shared visitors cannot be told apart (see Chat\ClientAddress::identify): a declared proxy that reported no visitor address, or * a private/loopback address carrying forwarding headers from a proxy the operator has not declared. A forwarding header * sent from a PUBLIC address never lands here, so it is not a way to choose a different allowance. * unknown no usable address: one bucket for all such requests (normally none). * shared and unknown traffic gets a modest combined allowance (SHARED_MULTIPLIER times the daily amount, at most * SHARED_SHARE_PERCENT of the site limit) and can only exhaust itself; identified clients keep their allowances. Both are a * misconfiguration signal, so the administrator is alerted as soon as they appear. */ final class ClientBudget { public const KIND_CLIENT = 'client'; public const KIND_SHARED = 'shared'; public const KIND_UNKNOWN = 'unknown'; public const OPTION_DAILY = 'aiwend_client_daily_units'; public const OPTION_SHARE = 'aiwend_client_share_percent'; /** About three default-sized conversations a day (a real answer reserves roughly 1,500 to 3,500 units). */ public const DEFAULT_DAILY_UNITS = 60000; public const MIN_DAILY_UNITS = 1000; public const MAX_DAILY_UNITS = 100000000; /** One client may use at most this share of the site limit (on top of the absolute daily amount). */ public const DEFAULT_SHARE_PERCENT = 20; /** A tiny site limit never starves a client below one or two answers. */ public const FLOOR_UNITS = 5000; /** Larger allowance for traffic that cannot be told apart (shared and unknown buckets). */ public const SHARED_MULTIPLIER = 3; public const SHARED_SHARE_PERCENT = 25; /** An unusable value (not a whole number, out of range) falls back to the default; it never switches the limit off. */ public static function dailyUnits(mixed $value): int { $n = self::wholeNumber($value); return $n !== null && $n >= self::MIN_DAILY_UNITS && $n <= self::MAX_DAILY_UNITS ? $n : self::DEFAULT_DAILY_UNITS; } public static function sharePercent(mixed $value): int { $n = self::wholeNumber($value); return $n !== null && $n >= 1 && $n <= 100 ? $n : self::DEFAULT_SHARE_PERCENT; } /** @param string $clientId 'u' or 'a
'; '' = unknown */ public static function bucket(string $kind, string $clientId, string $secret, int $now): string { $day = gmdate('Ymd', $now); if ($kind === self::KIND_SHARED) { return "client:$day-shared"; } if ($kind === self::KIND_UNKNOWN || $clientId === '') { return "client:$day-unknown"; } $dayKey = hash_hmac('sha256', 'aiwend-client-budget|' . $day, $secret, true); $token = substr(hash_hmac('sha256', $clientId, $dayKey), 0, 32); // 128 bits, hex return "client:$day-$token"; } /** Units for a client bucket created now: the smaller of the daily amount and the site-share amount (never below the floor). */ public static function limit(string $kind, int $daily, int $sharePercent, int $siteLimit): int { if ($kind !== self::KIND_CLIENT) { $daily *= self::SHARED_MULTIPLIER; $sharePercent = self::SHARED_SHARE_PERCENT; } $floor = min(self::FLOOR_UNITS, $daily); return min($daily, max($floor, intdiv(max(0, $siteLimit) * $sharePercent, 100))); } /** Which kind of bucket a name belongs to, or null for a bucket that is not a client bucket. */ public static function kindOf(string $bucket): ?string { if (preg_match('/^client:\d{8}-([A-Za-z0-9_-]+)$/', $bucket, $m) !== 1) { return null; } return match ($m[1]) { 'shared' => self::KIND_SHARED, 'unknown' => self::KIND_UNKNOWN, default => self::KIND_CLIENT, }; } private static function wholeNumber(mixed $value): ?int { if (is_int($value)) { return $value; } if (is_string($value) && preg_match('/^[0-9]{1,9}$/', $value) === 1) { return (int) $value; } if (is_float($value) && floor($value) === $value && $value >= 0 && $value <= 999999999) { return (int) $value; } return null; } }