feat: rate limiting + internal health monitor + secure VAPID note
Rate limiting: - Shop POST endpoints get per-IP throttles with distinct prefixes so login, register, password-email, and password-reset have separate buckets: login/register/pw-reset = 5/min, pw-email = 3/min - OcrInvoiceService gates per-tenant via RateLimiter (30/hour) so a runaway uploader can't burn Claude Vision spend Health monitor (poor-man's monitoring): - HealthCheckCommand probes DB (SELECT 1), cache write/read, public storage write/read, and most-recent backup age. On any failure, pushes a Telegram alert via HEALTH_ALERT_BOT_TOKEN/HEALTH_ALERT_CHAT_ID. Dedups identical failures within a 30-min window via cache. - Scheduled every 10 min. Pair with external uptime monitoring (UptimeRobot, Better Stack hitting /up) for total-outage coverage. - .env.example documents the two new env vars. VAPID secret hygiene: - credentials.md no longer stores the VAPID_PRIVATE_KEY; the source of truth is the Coolify env on the autocrm app. Doc points to where to read it (UI or API). Mitigates accidental git leak. Tests (4 new): - shop login throttles after 5 attempts (6th = 429); register throttle is independent of login (separate prefix); health command runs clean; dedup cache path exercised Full suite: 138 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Lightweight self-health probe. Runs on the box, so it can't catch total
|
||||
* outages — pair with external uptime monitoring (UptimeRobot, Better Stack)
|
||||
* pointing at /up.
|
||||
*
|
||||
* Tests:
|
||||
* - DB connectivity (central connection: SELECT 1)
|
||||
* - Cache write/read (Redis if configured)
|
||||
* - Public storage disk write/read
|
||||
* - Most recent tenant backup age (warn if > 30h)
|
||||
*
|
||||
* On any failure, pushes a short Telegram alert to HEALTH_ALERT_CHAT_ID via
|
||||
* HEALTH_ALERT_BOT_TOKEN (env). Dedups identical failures within 30 minutes
|
||||
* via cache to avoid spamming on each cron tick.
|
||||
*/
|
||||
class HealthCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'health:check
|
||||
{--silent : Do not echo OK output (for cron)}';
|
||||
|
||||
protected $description = 'Probe DB / cache / storage / backup freshness; alert via Telegram on failure.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
try {
|
||||
DB::connection()->select('SELECT 1');
|
||||
} catch (\Throwable $e) {
|
||||
$issues[] = 'DB: ' . substr($e->getMessage(), 0, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
$stamp = 'hc:' . (string) microtime(true);
|
||||
Cache::put('health:probe', $stamp, 30);
|
||||
if (Cache::get('health:probe') !== $stamp) {
|
||||
$issues[] = 'Cache: write/read mismatch';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$issues[] = 'Cache: ' . substr($e->getMessage(), 0, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
$f = 'health/' . md5((string) microtime(true)) . '.txt';
|
||||
Storage::disk('public')->put($f, 'ok');
|
||||
if (Storage::disk('public')->get($f) !== 'ok') {
|
||||
$issues[] = 'Storage: write/read mismatch';
|
||||
}
|
||||
Storage::disk('public')->delete($f);
|
||||
} catch (\Throwable $e) {
|
||||
$issues[] = 'Storage: ' . substr($e->getMessage(), 0, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
$newest = collect(Storage::disk('local')->allFiles('backups'))
|
||||
->map(fn ($f) => Storage::disk('local')->lastModified($f))
|
||||
->max();
|
||||
if ($newest && (time() - $newest) > 30 * 3600) {
|
||||
$age = round((time() - $newest) / 3600, 1);
|
||||
$issues[] = "Backup: cel mai recent are {$age}h (expectat <30h).";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Backup folder might be empty on a fresh install — not an alert.
|
||||
}
|
||||
|
||||
if (empty($issues)) {
|
||||
if (! $this->option('silent')) {
|
||||
$this->info('Health OK · ' . now()->toIso8601String());
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$signature = md5(implode('|', $issues));
|
||||
$dedupKey = "health:alert:{$signature}";
|
||||
if (! Cache::has($dedupKey)) {
|
||||
$this->pushTelegramAlert($issues);
|
||||
Cache::put($dedupKey, 1, 30 * 60); // 30-min cooldown per fingerprint
|
||||
}
|
||||
|
||||
foreach ($issues as $i) $this->error($i);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function pushTelegramAlert(array $issues): void
|
||||
{
|
||||
$bot = env('HEALTH_ALERT_BOT_TOKEN');
|
||||
$chat = env('HEALTH_ALERT_CHAT_ID');
|
||||
if (! $bot || ! $chat) {
|
||||
Log::warning('health:check failed but HEALTH_ALERT_BOT_TOKEN/CHAT_ID not set', [
|
||||
'issues' => $issues,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$body = "🚨 <b>AutoCRM health alert</b>\n"
|
||||
. implode("\n", array_map(fn ($i) => '• ' . htmlspecialchars($i), $issues))
|
||||
. "\n\n" . config('app.url', 'service.mir.md');
|
||||
|
||||
try {
|
||||
Http::asJson()
|
||||
->timeout(10)
|
||||
->post("https://api.telegram.org/bot{$bot}/sendMessage", [
|
||||
'chat_id' => $chat,
|
||||
'text' => $body,
|
||||
'parse_mode' => 'HTML',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('health:check telegram alert failed', ['err' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,15 @@ class OcrInvoiceService
|
||||
return ['ok' => false, 'error' => 'Tenant nerezolvat.'];
|
||||
}
|
||||
|
||||
// Per-tenant rate limit — caps Claude Vision spend even if a user
|
||||
// accidentally (or maliciously) submits many invoices.
|
||||
$key = 'ocr-invoice:' . $company->id;
|
||||
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($key, 30)) {
|
||||
$retry = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
|
||||
return ['ok' => false, 'error' => "Prea multe importuri OCR. Reîncearcă în {$retry} sec."];
|
||||
}
|
||||
\Illuminate\Support\Facades\RateLimiter::hit($key, 3600); // 30 / hour
|
||||
|
||||
$key = data_get($company->settings, 'ai.claude_key');
|
||||
if (! $key) {
|
||||
return ['ok' => false, 'error' => '⚠️ Lipsește cheia Claude în Setări → AI.'];
|
||||
|
||||
Reference in New Issue
Block a user