diff --git a/.env.example b/.env.example index 523ca01..8d0d283 100644 --- a/.env.example +++ b/.env.example @@ -17,27 +17,44 @@ DB_NAME=fitme3070 DB_USER=root DB_PASS= -# ─── Notifications (email + SMS) ───────────────────────────────────────────── -# Delivery is centralized at id.andmore.pt (/notify). This app does not send -# mail/SMS itself. Auth + endpoint are ANDMORE_ID_URL + NOTIFY_API_KEY below. +# ─── Email / SMTP ─────────────────────────────────────────────── +# This app sends its own mail again (PHPMailer over SMTP) — nothing is relayed +# through the hub any more. Anything set here WINS over the same setting in +# Sistema → Comunicações, so leave these blank if you want to manage SMTP from +# the backoffice. Empty SMTP_HOST falls back to PHP's mail(). +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=tls +SMTP_USERNAME= +SMTP_PASSWORD= -# ─── Backup ───────────────────────────────────────────────────────────────── -BACKUP_TOKEN=replace_me -BACKUP_KEEP_DAYS=7 +# ─── Encryption key for settings stored in the DB ───────────────────────── +# smtp_password / twilio_token are AES-256-GCM encrypted at rest so they are not +# readable in a database dump. Without a key they are stored in plain text and +# the Comunicações page warns about it. Any long random string works. +# (BACKUP_TOKEN is used as a fallback when this is unset.) +APP_SECRET_KEY= -# ─── Andmore hub (notifications only) ──────────────────────────────────────── -# This install owns its own login: passwords live in users.password_hash and -# views/auth/login.php is a real form. The Andmore ID / SSO delegation was -# removed — no IDENTITY_MODE, no SSO_* keys, no central sso_sessions table. -# -# ANDMORE_ID_URL stays only because outbound email/SMS is still relayed through -# the hub's /notify endpoint (see NOTIFY_API_KEY below). -ANDMORE_ID_URL=https://hub.andmore.pt/ +# ─── Twilio (SMS / WhatsApp) — optional ─────────────────────────────── +# Leave empty unless the sms/whatsapp channels are actually used. Email works +# without them. +TWILIO_SID= +TWILIO_TOKEN= +TWILIO_SMS_FROM= +TWILIO_WHATSAPP_FROM= -# ─── Notificações centralizadas (id.andmore.pt /notify) ────────────────────── -# App key for POST {ANDMORE_ID_URL}notify/send. Minted on id.andmore.pt → -# Notificações → Chaves de app. -NOTIFY_API_KEY= +# ─── Dev safety nets (ignored when APP_ENV=prod) ─────────────────────── +# EMAIL_DEV_REDIRECT — send every message here instead of the real recipient. +# EMAIL_DEV_PRINT — skip SMTP entirely, log to logs/message_dev.log. Wins. +EMAIL_DEV_REDIRECT= +EMAIL_DEV_PRINT=false + +# ─── Cron ──────────────────────────────────────────────────────── +# cron.php drains the message queue every 5 minutes. Over the shell no token is +# needed. Over HTTP it is the only protection, and cron.php answers 404 until it +# is set to something other than replace_me: +# */5 * * * * curl -fsS "https://fitme3070.pt/cron.php?token=" +CRON_TOKEN=replace_me # ─── Hub social content endpoint (api/hub-content.php) ───────────────────────── # Shared secret the hub social-post generator signs its pull requests with. diff --git a/config/config.php b/config/config.php index 62c1773..e0797fa 100644 --- a/config/config.php +++ b/config/config.php @@ -13,9 +13,9 @@ // ─── Environment ────────────────────────────────────────────────────────────── define('APP_ENV', env('APP_ENV', 'prod')); -// Email/SMS delivery — including the dev redirect/print safety net — now lives at -// id.andmore.pt (/notify). See config/functions_notify_client.php. NOTIFY_API_KEY -// (in .env) authenticates this app to the central service. +// Email/SMS delivery — including the dev redirect/print safety net — happens in +// this app. See config/functions_queue.php (message_queue + PHPMailer over SMTP) +// and config/functions.php for the SMTP_* constants. if (APP_ENV !== 'prod') { ini_set('display_errors', '1'); @@ -27,14 +27,3 @@ if (APP_ENV !== 'prod') { } require_once __DIR__ . '/error_handler.php'; - -// ─── Andmore hub (notifications only) ──────────────────────────────────────── -// This install owns its own identity: passwords live in `users.password_hash` -// and views/auth/login.php is a real form (see config/auth.php). The former -// Andmore ID / SSO delegation — config/sso.php, the am_sso cookie, the central -// sso_sessions table and the one-time ticket redeem — was removed. -// -// ANDMORE_ID_URL survives because outbound email/SMS is still centralized at -// the hub's /notify endpoint; see config/functions_notify_client.php. It has -// nothing to do with authentication anymore. -define('ANDMORE_ID_URL', rtrim(env('ANDMORE_ID_URL', 'https://hub.andmore.pt'), '/') . '/'); diff --git a/config/functions.php b/config/functions.php index 4bdc8f0..4551269 100644 --- a/config/functions.php +++ b/config/functions.php @@ -11,15 +11,15 @@ if (session_status() === PHP_SESSION_NONE) session_start(); require_once __DIR__ . '/config.php'; // APP_ENV + hub notify URL + error_handler require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache +require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_secret() + AES-GCM at rest require_once __DIR__ . '/functions_db.php'; // generic DB helpers require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner -// Outbound delivery is centralized at id.andmore.pt — no local PHPMailer/SMTP. -require_once __DIR__ . '/functions_notify_client.php'; // notify_remote_send() -require_once __DIR__ . '/functions_queue.php'; // enqueue_*/flush_message façade → relay +// Outbound delivery happens in this app — PHPMailer over SMTP, no central relay. +require_once __DIR__ . '/functions_queue.php'; // message_queue + enqueue_*/flush_message require_once __DIR__ . '/functions_csrf.php'; require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed) require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session) @@ -48,6 +48,34 @@ define('EMAIL_FROM', (string) setting('email_from', 'noreply define('EMAIL_FROM_NAME', (string) setting('email_from_name', 'FitMe 3070')); define('EMAIL_SUBJECT_PREFIX', (string) setting('email_subject_prefix', '[FitMe 3070] ')); +// ─── SMTP ─────────────────────────────────────────────────────────── +// .env wins, then system_settings (smtp_password encrypted at rest). These +// constants are what the diagnostics page shows; the actual send resolves +// through platform_smtp_config($pdo), which is DB-first — that is the value an +// operator can change from the backoffice without shell access. +// An empty SMTP_HOST makes PHPMailer fall back to PHP mail(). +define('SMTP_HOST', setting_or_env('smtp_host', 'SMTP_HOST')); +define('SMTP_PORT', (int) (setting_or_env('smtp_port', 'SMTP_PORT', '587') ?: 587)); +define('SMTP_SECURE', setting_or_env('smtp_secure', 'SMTP_SECURE', 'tls')); +define('SMTP_USERNAME', setting_or_env('smtp_username', 'SMTP_USERNAME')); +define('SMTP_PASSWORD', setting_secret('smtp_password', 'SMTP_PASSWORD')); + +// ─── Twilio (SMS / WhatsApp) ─────────────────────────────────────── +// Optional. Left empty the sms/whatsapp drivers refuse with a clear message +// instead of half-sending; email is unaffected. +define('TWILIO_SID', setting_or_env('twilio_sid', 'TWILIO_SID')); +define('TWILIO_TOKEN', setting_secret('twilio_token', 'TWILIO_TOKEN')); +define('TWILIO_SMS_FROM', setting_or_env('twilio_sms_from', 'TWILIO_SMS_FROM')); +define('TWILIO_WHATSAPP_FROM', setting_or_env('twilio_whatsapp_from', 'TWILIO_WHATSAPP_FROM')); + +// ─── Dev safety nets ─────────────────────────────────────────────── +// Only consulted when APP_ENV !== 'prod', so a misconfigured production install +// can never silently swallow mail. +// EMAIL_DEV_PRINT — skip SMTP entirely, write to logs/message_dev.log. +// EMAIL_DEV_REDIRECT — send everything to this address instead of the real one. +define('EMAIL_DEV_PRINT', filter_var(env('EMAIL_DEV_PRINT', 'false'), FILTER_VALIDATE_BOOLEAN)); +define('EMAIL_DEV_REDIRECT', (string) env('EMAIL_DEV_REDIRECT', '')); + // ─── URL helper ─────────────────────────────────────────────────────────────── // Always use for Location: redirects and internal links. function base_url(string $path = ''): string { diff --git a/config/functions_notify_client.php b/config/functions_notify_client.php deleted file mode 100644 index 9c1dd84..0000000 --- a/config/functions_notify_client.php +++ /dev/null @@ -1,50 +0,0 @@ - false, 'error' => 'notify_not_configured']; - } - - $payload['app_key'] = $key; - $url = rtrim($base, '/') . '/notify/send.php'; - - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 20, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ]); - $resp = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $err = curl_error($ch); - curl_close($ch); - - if ($resp === false) { - error_log("notify_remote_send: transport error ($err) calling $url"); - return ['ok' => false, 'error' => 'transport_error']; - } - $data = json_decode($resp, true); - if (!is_array($data)) { - error_log("notify_remote_send: bad response (HTTP $code) from $url: " . substr((string) $resp, 0, 300)); - return ['ok' => false, 'error' => 'bad_response', 'http' => $code]; - } - if (empty($data['ok'])) { - error_log("notify_remote_send: notify error (HTTP $code): " . ($data['error'] ?? '?')); - } - return $data; -} diff --git a/config/functions_queue.php b/config/functions_queue.php index d8f13a8..91e9854 100644 --- a/config/functions_queue.php +++ b/config/functions_queue.php @@ -1,66 +1,552 @@ $channel] + $data; - return $id; +function enqueue_email(array $data): int { + return _enqueue_message('email', $data); } -// Relay a staged message to id.andmore.pt and return true on success. The local -// `to_user_id` is intentionally NOT forwarded — it's a fotografia id, not a -// canonical id.andmore.pt user id (the central queue FK would reject it). The -// recipient address + this app's key are enough; the central log records origin. +function enqueue_sms(array $data): int { + return _enqueue_message('sms', $data); +} + +function enqueue_whatsapp(array $data): int { + return _enqueue_message('whatsapp', $data); +} + +function _enqueue_message(string $channel, array $data): int { + global $pdo; + $stmt = $pdo->prepare(" + INSERT INTO message_queue + (channel, to_user_id, to_address, to_name, reply_to, subject, body, alt_body, + context_type, context_id, scheduled_for, max_attempts, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $channel, + $data['to_user_id'] ?? null, + $data['to_address'], + $data['to_name'] ?? null, + // Sem isto, responder ao aviso de contacto respondia ao remetente da + // casa e nao a quem escreveu -- o flush_message() ja o passava ao + // mailer, era o INSERT que nao o guardava. + $data['reply_to'] ?? null, + $data['subject'] ?? null, + $data['body'], + $data['alt_body'] ?? null, + $data['context_type'] ?? null, + $data['context_id'] ?? null, + $data['scheduled_for'] ?? null, + $data['max_attempts'] ?? 3, + $data['created_by'] ?? ($_SESSION['user']['id'] ?? null), + ]); + return (int) $pdo->lastInsertId(); +} + +// ─── Flush a single message ────────────────────────────────────────────────── + +// Atomically claims a pending/failed row, sends it, updates status. +// Returns true on send success, false otherwise. function flush_message(int $id): bool { - $data = $GLOBALS['_notify_staged'][$id] ?? null; - if (!$data) return false; - unset($GLOBALS['_notify_staged'][$id]); + global $pdo; - $channel = $data['channel'] ?? 'email'; - $payload = [ - 'channel' => $channel, - 'to' => $data['to_address'] ?? '', - 'to_name' => $data['to_name'] ?? null, - 'body' => $data['body'] ?? '', - ]; - if ($channel === 'email') { - // Preserve the historical subject prefix that the old SMTP path applied. - $prefix = defined('EMAIL_SUBJECT_PREFIX') ? EMAIL_SUBJECT_PREFIX : ''; - $payload['subject'] = $prefix . ($data['subject'] ?? ''); - $payload['alt_body'] = $data['alt_body'] ?? null; - } - if (!empty($data['context_type'])) { - $payload['context'] = ['type' => $data['context_type'], 'id' => $data['context_id'] ?? null]; - } - if (!empty($data['scheduled_for'])) { - $payload['scheduled_for'] = $data['scheduled_for']; - } + // Atomic claim — only one worker proceeds. Guards against the cron run and + // an inline flush racing for the same row. + $claim = $pdo->prepare(" + UPDATE message_queue + SET status = 'sending', attempts = attempts + 1 + WHERE id = ? + AND status IN ('pending', 'failed') + "); + $claim->execute([$id]); + if ($claim->rowCount() === 0) return false; - $r = notify_remote_send($payload); - return !empty($r['ok']) && (($r['status'] ?? '') !== 'failed'); + $row = db_get_message($pdo, $id); + if (!$row) return false; + + try { + match ($row['channel']) { + 'email' => _flush_email($row), + 'sms' => _flush_twilio($row, 'sms'), + 'whatsapp' => _flush_twilio($row, 'whatsapp'), + default => throw new RuntimeException("Unknown channel: {$row['channel']}"), + }; + + $pdo->prepare("UPDATE message_queue SET status='sent', sent_at=NOW(), last_error=NULL, next_attempt_at=NULL WHERE id=?")->execute([$id]); + return true; + } catch (\Throwable $e) { + // `attempts` was already incremented by the claim, so it reflects this + // attempt. Once it reaches max_attempts the row parks in 'failed' and + // only a manual retry (Monitor page) revives it. + $maxed = ((int) $row['attempts']) >= ((int) $row['max_attempts']); + $newStatus = $maxed ? 'failed' : 'pending'; + // Exponential backoff: 2^attempts minutes (2, 4, 8, ...) + $nextAt = $maxed ? null : date('Y-m-d H:i:s', time() + (60 * (2 ** (int) $row['attempts']))); + $pdo->prepare("UPDATE message_queue SET status=?, last_error=?, next_attempt_at=? WHERE id=?") + ->execute([$newStatus, mb_substr($e->getMessage(), 0, 2000), $nextAt, $id]); + error_log("flush_message($id) failed: {$e->getMessage()}"); + return false; + } } -// No local queue to drain anymore. Kept as a harmless no-op so any stray caller -// (or scheduled task that wasn't updated) doesn't fatal. +// ─── Driver: email (PHPMailer / SMTP) ──────────────────────────────────────── + +function _flush_email(array $row): void { + // Dev-mode debug print: skip SMTP entirely, write the email to a log file + // and a short summary to PHP's error_log. Kept here rather than in the + // shared transport because it writes this app's own log format. + if (APP_ENV !== 'prod' && EMAIL_DEV_PRINT) { + _dev_print_message($row, 'EMAIL'); + return; + } + + // Transport is shared with the rest of the fleet (fotografia, hub). It applies + // the EMAIL_DEV_REDIRECT safety net itself, including the [DEV] subject + // prefix and the banner naming the original recipient, so that logic no + // longer lives here. + require_once __DIR__ . '/../shared/platform-tools/functions_mail.php'; + require_once __DIR__ . '/../shared/platform-tools/functions_settings.php'; + + // Settings come from system_settings first, .env second -- the fleet-wide + // standard. This install stores them there via setting_or_env(); this + // just reads them through the shared accessor instead of the local + // constants, so all three platforms resolve SMTP identically. + $r = platform_mail_send([ + 'to' => $row['to_address'], + 'to_name' => $row['to_name'] ?? '', + // A fila não deve depender de uma constante que não é dela. O + // EMAIL_SUBJECT_PREFIX é definido no functions.php, e o index.php não o + // inclui de propósito -- o resultado era «Undefined constant» apanhado + // pelo flush_message() e gravado em last_error, com a página a dizer só + // "Ocorreu um erro" e nada no log do Apache. + 'subject' => (defined('EMAIL_SUBJECT_PREFIX') ? EMAIL_SUBJECT_PREFIX : '') . ($row['subject'] ?? ''), + 'body' => $row['body'], + 'alt_body' => $row['alt_body'] ?: strip_tags($row['body']), + 'reply_to' => $row['reply_to'] ?? '', + // O 4º argumento liga o tecto de envio (functions_mail_rate): 20/minuto e + // 200/hora, partilhados com o resto da quinta. Sem o $pdo o tecto não se + // aplica -- o módulo estava aqui e não estava a ser usado. + ], dirname(__DIR__), platform_smtp_config($GLOBALS['pdo']), $GLOBALS['pdo'] ?? null); + + // flush_message() treats a thrown exception as "retry later" and records + // last_error, so a failure must throw rather than return quietly. + if (empty($r['ok'])) { + throw new RuntimeException($r['error'] ?? 'Envio de email falhou.'); + } +} + +// ─── Driver: SMS + WhatsApp (Twilio Messages API) ──────────────────────────── + +// Both channels hit the same endpoint; only the sender differs (WhatsApp needs +// the `whatsapp:` prefix on both From and To). Throws on any non-2xx so +// flush_message() records the reason and schedules a retry. +function _flush_twilio(array $row, string $channel): void { + if (APP_ENV !== 'prod' && SMS_DEV_PRINT) { + _dev_print_message($row, strtoupper($channel)); + return; + } + + if (TWILIO_SID === '' || TWILIO_TOKEN === '') { + throw new RuntimeException('Twilio não configurado (TWILIO_SID / TWILIO_TOKEN em falta no .env)'); + } + + $from = $channel === 'whatsapp' ? TWILIO_WHATSAPP_FROM : TWILIO_SMS_FROM; + if ($from === '') { + $var = $channel === 'whatsapp' ? 'TWILIO_WHATSAPP_FROM' : 'TWILIO_SMS_FROM'; + throw new RuntimeException("Remetente não configurado ({$var} em falta no .env)"); + } + + $to = sms_normalize_number($row['to_address']); + if ($to === null) { + throw new RuntimeException("Número de telefone inválido: {$row['to_address']}"); + } + + if ($channel === 'whatsapp') { + $from = 'whatsapp:' . $from; + $to = 'whatsapp:' . $to; + } + + // SMS/WhatsApp are plain text. Bodies built from the HTML email templates + // would arrive as raw markup, so strip tags defensively. + $body = trim($row['body']); + if ($body !== strip_tags($body)) { + $body = trim(html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + if ($body === '') { + throw new RuntimeException('Mensagem vazia'); + } + + $url = 'https://api.twilio.com/2010-04-01/Accounts/' . rawurlencode(TWILIO_SID) . '/Messages.json'; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_USERPWD => TWILIO_SID . ':' . TWILIO_TOKEN, + CURLOPT_POSTFIELDS => http_build_query([ + 'From' => $from, + 'To' => $to, + 'Body' => $body, + ]), + ]); + $resp = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($resp === false) { + throw new RuntimeException("Erro de ligação ao Twilio: {$err}"); + } + if ($code < 200 || $code >= 300) { + // Twilio returns {"code":21211,"message":"Invalid 'To' Phone Number"}. + // Surfacing that message is what makes the Monitor page actionable. + $data = json_decode((string) $resp, true); + $detail = is_array($data) + ? trim(($data['message'] ?? '') . (isset($data['code']) ? " (Twilio {$data['code']})" : '')) + : substr((string) $resp, 0, 300); + throw new RuntimeException("Twilio HTTP {$code}: " . ($detail !== '' ? $detail : 'sem detalhe')); + } +} + +// Coerces a stored phone into E.164. Accepts "+351915435034", "351915435034", +// "915435034" and anything with spaces/dashes. Returns null when it can't. +function sms_normalize_number(string $raw): ?string { + $raw = trim($raw); + $hasPlus = str_starts_with($raw, '+'); + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + if ($digits === '') return null; + + if ($hasPlus) { + return strlen($digits) >= 8 ? '+' . $digits : null; + } + + $cc = preg_replace('/\D+/', '', SMS_DEFAULT_COUNTRY_CODE) ?? ''; + // Already carries the country code (e.g. 351915435034) — just add the plus. + if ($cc !== '' && str_starts_with($digits, $cc) && strlen($digits) > strlen($cc)) { + return '+' . $digits; + } + // National number — prefix the default country code. + if ($cc !== '' && strlen($digits) >= 9) { + return '+' . $cc . $digits; + } + return strlen($digits) >= 11 ? '+' . $digits : null; +} + +// Inverse of sms_normalize_number(): the human-readable form used for the +// printed contact on the public site. A national number under the default +// country code drops the prefix and is grouped in threes ("+351928363069" -> +// "928 363 069"); anything else keeps the international form, which is the +// only unambiguous way to write it. +function sms_format_display(string $e164): string { + $cc = preg_replace('/\D+/', '', SMS_DEFAULT_COUNTRY_CODE) ?? ''; + $digits = preg_replace('/\D+/', '', $e164) ?? ''; + if ($cc !== '' && str_starts_with($digits, $cc)) { + $national = substr($digits, strlen($cc)); + if (strlen($national) === 9) { + return trim(chunk_split($national, 3, ' ')); + } + } + return $e164; +} + +// Writes the full message to logs/message_dev.log and a summary line to PHP's +// error_log. Only called under APP_ENV != prod + EMAIL_DEV_PRINT / SMS_DEV_PRINT. +function _dev_print_message(array $row, string $label): void { + $logDir = __DIR__ . '/../logs'; + $logFile = $logDir . '/message_dev.log'; + if (!is_dir($logDir)) @mkdir($logDir, 0775, true); + + $sep = str_repeat('─', 70) . "\n"; + $entry = $sep + . sprintf("[%s] %s (DEV PRINT — não enviado)\n", date('Y-m-d H:i:s'), $label) + . sprintf("To: %s%s\n", $row['to_address'], $row['to_name'] ? " <{$row['to_name']}>" : '') + . sprintf("Subject: %s\n", EMAIL_SUBJECT_PREFIX . ($row['subject'] ?? '')) + . sprintf("Context: %s#%s\n", $row['context_type'] ?? '?', $row['context_id'] ?? '?') + . $sep + . ($row['body'] ?? '') . "\n\n"; + @file_put_contents($logFile, $entry, FILE_APPEND); + + error_log(sprintf( + '[%s-dev] %s → %s (registado em logs/message_dev.log)', + strtolower($label), + $row['subject'] ?? '(sem assunto)', + $row['to_address'] + )); +} + +// ─── Drain the queue ───────────────────────────────────────────────────────── + +// Picks up to $limit due rows and processes them in turn. Returns counts. +// Called by cron.php every 5 minutes; also safe to call inline after a bulk +// enqueue. function process_queue(int $limit = 20): array { - return ['sent' => 0, 'failed' => 0, 'total' => 0]; + global $pdo; + $limit = max(1, $limit); + $rows = $pdo->query(" + SELECT id FROM message_queue + WHERE status = 'pending' + AND deleted_at IS NULL + AND (scheduled_for IS NULL OR scheduled_for <= NOW()) + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ORDER BY scheduled_for ASC, id ASC + LIMIT $limit + ")->fetchAll(); + + $sent = 0; $failed = 0; + foreach ($rows as $r) { + if (flush_message((int) $r['id'])) $sent++; else $failed++; + } + return ['sent' => $sent, 'failed' => $failed, 'total' => count($rows)]; +} + +// Rows stuck in 'sending' mean a worker died mid-send (fatal, timeout, deploy). +// Nothing would ever pick them up again, so the cron releases them back to +// 'pending' once they're older than $minutes. +function requeue_stuck_messages(PDO $pdo, int $minutes = 15): int { + $minutes = max(1, $minutes); + return (int) $pdo->exec(" + UPDATE message_queue + SET status = 'pending', + last_error = CONCAT('Retomado após bloqueio em \"sending\" (', COALESCE(last_error, ''), ')') + WHERE status = 'sending' + AND updated_at < NOW() - INTERVAL $minutes MINUTE + "); +} + +// ─── CRUD helpers (used by the Monitor admin page) ─────────────────────────── + +function db_get_message(PDO $pdo, int $id): array|false { + $stmt = $pdo->prepare("SELECT * FROM message_queue WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->fetch(); +} + +/** + * Fetch messages with rich filters. + * + * $filters keys: + * - statuses array one or more valid statuses (OR) + * - channel string 'email' | 'sms' | 'whatsapp' + * - search string LIKE on to_address / to_name / subject + * - days int|null limit to created_at within N days + * - include_deleted bool default false (exclude soft-deleted) + * - only_deleted bool default false (only soft-deleted) + * - limit int default 200 + */ +function db_get_messages(PDO $pdo, array $filters = []): array { + $statuses = $filters['statuses'] ?? []; + $channel = $filters['channel'] ?? null; + $search = trim((string) ($filters['search'] ?? '')); + $days = isset($filters['days']) ? (int) $filters['days'] : null; + $include_deleted = !empty($filters['include_deleted']); + $only_deleted = !empty($filters['only_deleted']); + $limit = max(1, (int) ($filters['limit'] ?? 200)); + + $where = []; + $args = []; + + $statuses = array_values(array_intersect($statuses, MESSAGE_QUEUE_VALID_STATUSES)); + if ($statuses) { + $where[] = 'q.status IN (' . implode(',', array_fill(0, count($statuses), '?')) . ')'; + foreach ($statuses as $s) $args[] = $s; + } + if ($channel && in_array($channel, MESSAGE_QUEUE_VALID_CHANNELS, true)) { + $where[] = 'q.channel = ?'; + $args[] = $channel; + } + if ($search !== '') { + $where[] = '(q.to_address LIKE ? OR q.to_name LIKE ? OR q.subject LIKE ?)'; + $like = '%' . $search . '%'; + $args[] = $like; $args[] = $like; $args[] = $like; + } + if ($days !== null && $days > 0) { + $where[] = "q.created_at >= NOW() - INTERVAL $days DAY"; + } + if ($only_deleted) { + $where[] = 'q.deleted_at IS NOT NULL'; + } elseif (!$include_deleted) { + $where[] = 'q.deleted_at IS NULL'; + } + + $sql = " + SELECT q.*, u.name AS user_name + FROM message_queue q + LEFT JOIN users u ON q.to_user_id = u.id + "; + if ($where) $sql .= ' WHERE ' . implode(' AND ', $where); + $sql .= " ORDER BY q.created_at DESC LIMIT $limit"; + $stmt = $pdo->prepare($sql); + $stmt->execute($args); + return $stmt->fetchAll(); +} + +/** + * Status counts (excluding soft-deleted by default). + * Optional $days limits the window. Adds a virtual 'deleted' key with the + * count of soft-deleted rows. + */ +function db_count_messages_by_status(PDO $pdo, ?int $days = null): array { + $where = ['deleted_at IS NULL']; + if ($days !== null && $days > 0) { + $where[] = "created_at >= NOW() - INTERVAL $days DAY"; + } + $whereSql = 'WHERE ' . implode(' AND ', $where); + $counts = $pdo->query(" + SELECT status, COUNT(*) AS n FROM message_queue $whereSql GROUP BY status + ")->fetchAll(PDO::FETCH_KEY_PAIR); + + foreach (MESSAGE_QUEUE_VALID_STATUSES as $s) $counts[$s] = (int) ($counts[$s] ?? 0); + $counts['deleted'] = (int) $pdo->query("SELECT COUNT(*) FROM message_queue WHERE deleted_at IS NOT NULL")->fetchColumn(); + return $counts; +} + +// Number of rows waiting for the next cron pass. Drives the navbar badge. +function db_count_pending_messages(PDO $pdo): int { + return (int) $pdo->query(" + SELECT COUNT(*) FROM message_queue + WHERE status IN ('pending','failed') AND deleted_at IS NULL + ")->fetchColumn(); +} + +function db_retry_message(PDO $pdo, int $id): void { + // Resetting `attempts` is what makes a retry meaningful — otherwise a row + // that already burned its 3 attempts would fail straight back to 'failed' + // on the first error without any backoff. + $pdo->prepare(" + UPDATE message_queue + SET status='pending', attempts=0, next_attempt_at=NULL, last_error=NULL, deleted_at=NULL + WHERE id=? AND status IN ('failed','cancelled') + ")->execute([$id]); +} + +function db_cancel_message(PDO $pdo, int $id): void { + $pdo->prepare(" + UPDATE message_queue + SET status='cancelled' + WHERE id=? AND status IN ('pending','failed') + ")->execute([$id]); +} + +function db_soft_delete_message(PDO $pdo, int $id): void { + $pdo->prepare(" + UPDATE message_queue + SET deleted_at = NOW() + WHERE id = ? AND deleted_at IS NULL AND status <> 'sending' + ")->execute([$id]); +} + +function db_restore_message(PDO $pdo, int $id): void { + $pdo->prepare(" + UPDATE message_queue + SET deleted_at = NULL + WHERE id = ? + ")->execute([$id]); +} + +/** Bulk soft-delete sent messages older than N days. Returns affected rows. */ +function db_soft_delete_sent_older_than(PDO $pdo, int $days): int { + $days = max(1, (int) $days); + return (int) $pdo->exec(" + UPDATE message_queue + SET deleted_at = NOW() + WHERE deleted_at IS NULL + AND status = 'sent' + AND sent_at < NOW() - INTERVAL $days DAY + "); +} + +/** Hard-delete rows soft-deleted more than N days ago. Called by cron.php. */ +function db_purge_deleted_messages(PDO $pdo, int $days): int { + $days = max(1, (int) $days); + return (int) $pdo->exec(" + DELETE FROM message_queue + WHERE deleted_at IS NOT NULL + AND deleted_at < NOW() - INTERVAL $days DAY + "); +} + +// ─── Display helpers ───────────────────────────────────────────────────────── + +function message_status_label(string $status): string { + return match ($status) { + 'pending' => 'Pendente', + 'sending' => 'A enviar', + 'sent' => 'Enviado', + 'failed' => 'Falhou', + 'cancelled' => 'Cancelado', + default => ucfirst($status), + }; +} + +function message_status_badge(string $status): string { + $colour = match ($status) { + 'pending' => 'secondary', + 'sending' => 'info', + 'sent' => 'success', + 'failed' => 'danger', + 'cancelled' => 'dark', + default => 'secondary', + }; + return '' . htmlspecialchars(message_status_label($status)) . ''; +} + +function message_channel_label(string $channel): string { + return match ($channel) { + 'email' => 'Email', + 'sms' => 'SMS', + 'whatsapp' => 'WhatsApp', + default => ucfirst($channel), + }; +} + +function message_channel_icon(string $channel): string { + return match ($channel) { + 'email' => 'bi-envelope', + 'sms' => 'bi-chat-left-text', + 'whatsapp' => 'bi-whatsapp', + default => 'bi-send', + }; +} + +function message_context_label(?string $type): string { + return match ($type) { + 'gallery_delivery' => 'Entrega de galeria', + 'session_scheduled' => 'Sessão agendada', + 'session_reminder' => 'Lembrete de sessão', + 'session_status' => 'Notificação de sessão', + 'print_job_status' => 'Notificação de encomenda', + 'password_reset' => 'Reposição de palavra-passe', + 'newsletter' => 'Newsletter', + 'broadcast' => 'Campanha (template)', + 'quote_pack' => 'Pack de orçamento', + 'quote_document' => 'Orçamento', + 'voucher' => 'Vale oferta', + 'contract_request' => 'Pedido de assinatura', + null, '' => '—', + default => $type, + }; } diff --git a/config/functions_secrets.php b/config/functions_secrets.php new file mode 100644 index 0000000..754d030 --- /dev/null +++ b/config/functions_secrets.php @@ -0,0 +1,54 @@ +/dev/null 2>&1 +// +// No shell? Hit it over HTTP with the shared secret from .env: +// */5 * * * * curl -fsS "https://fitme3070.pt/cron.php?token=" +// +// What it does, in order: +// 1. Releases rows stuck in 'sending' (a worker died mid-send). +// 2. Drains the message queue (email via SMTP, SMS/WhatsApp via Twilio). +// 3. Soft-deletes messages sent more than 90 days ago, then hard-deletes +// rows that have been soft-deleted for another 90. +// +// Every run is serialised with a lock file, so a slow SMTP server cannot cause +// two runs to drain the same rows. flush_message() claims each row atomically +// as well, so an inline send racing the cron is also safe — the lock is about +// not piling up runs, not about correctness. +// +// Deliberately NOT a copy of fotografia's cron: that one also queues session +// reminders, emails a download digest and compresses session photos. fitme3070 +// has none of those modules, so the steps would be dead code. +// ───────────────────────────────────────────────────────────────────────────── + +$cli = (PHP_SAPI === 'cli'); + +require __DIR__ . '/config/db.php'; +require __DIR__ . '/config/functions.php'; + +// ─── Access control (HTTP only) ────────────────────────────────────────────── +// Over the web the token is the only thing standing between the internet and a +// job that sends mail. Missing or wrong token = 404, not 403: an attacker +// probing for cron endpoints learns nothing from a 404. +if (!$cli) { + $expected = (string) env('CRON_TOKEN', ''); + $supplied = (string) ($_GET['token'] ?? ''); + if ($expected === '' || $expected === 'replace_me' + || !hash_equals($expected, $supplied)) { + http_response_code(404); + exit("Not Found\n"); + } + header('Content-Type: text/plain; charset=utf-8'); +} + +// ─── Single-run lock ───────────────────────────────────────────────────────── +$lockPath = __DIR__ . '/logs/cron.lock'; +@mkdir(dirname($lockPath), 0775, true); +$lock = @fopen($lockPath, 'c'); +if ($lock === false) { + echo "cron: não foi possível abrir o ficheiro de lock\n"; + exit(1); +} +if (!flock($lock, LOCK_EX | LOCK_NB)) { + echo "cron: já existe uma execução a decorrer — saltada.\n"; + exit(0); +} + +$started = microtime(true); +$log = []; +function cron_log(string $line): void { + global $log; + $log[] = $line; + echo $line . "\n"; +} + +cron_log('── cron ' . date('Y-m-d H:i:s') . ' ──'); + +// ─── 1. Release rows stuck in 'sending' ────────────────────────────────────── +try { + $released = requeue_stuck_messages($pdo, 15); + if ($released > 0) cron_log("mensagens libertadas de 'sending': {$released}"); +} catch (Throwable $e) { + cron_log('ERRO ao libertar mensagens presas: ' . $e->getMessage()); +} + +// ─── 2. Drain the queue ────────────────────────────────────────────────────── +try { + $r = process_queue(50); + if ($r['total'] > 0) { + cron_log("fila: {$r['total']} processada(s), {$r['sent']} enviada(s), {$r['failed']} falhada(s)"); + } +} catch (Throwable $e) { + cron_log('ERRO ao drenar a fila: ' . $e->getMessage()); +} + +// ─── 3. Retention ──────────────────────────────────────────────────────────── +// Once a day is plenty. Gated on the first 5-minute slot of 04:00 — the hour +// alone would still fire this twelve times. Bodies are longtext, so this is +// what stops the table growing without bound. +if ((int) date('G') === 4 && (int) date('i') < 5) { + try { + $soft = db_soft_delete_sent_older_than($pdo, 90); + $hard = db_purge_deleted_messages($pdo, 90); + if ($soft || $hard) cron_log("retenção: {$soft} arquivada(s), {$hard} eliminada(s)"); + } catch (Throwable $e) { + cron_log('ERRO na retenção: ' . $e->getMessage()); + } +} + +cron_log(sprintf('── fim (%.2fs) ──', microtime(true) - $started)); + +flock($lock, LOCK_UN); +fclose($lock); diff --git a/database/migration_20260913_message_queue.sql b/database/migration_20260913_message_queue.sql new file mode 100644 index 0000000..f2bff8f --- /dev/null +++ b/database/migration_20260913_message_queue.sql @@ -0,0 +1,42 @@ +-- Local outbound message queue. +-- +-- Added when fitme3070 stopped relaying mail through the hub's /notify endpoint +-- and went back to sending its own, PHPMailer over SMTP. Mirrors the +-- fotografia.andmore.pt table so the shared queue code ports unchanged. +-- +-- Safe to re-run. + +CREATE TABLE IF NOT EXISTS `message_queue` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `channel` ENUM('email','sms','whatsapp') NOT NULL DEFAULT 'email', + `status` ENUM('pending','sending','sent','failed','cancelled') NOT NULL DEFAULT 'pending', + `to_user_id` INT(11) DEFAULT NULL, + `to_address` VARCHAR(255) NOT NULL, + `to_name` VARCHAR(150) DEFAULT NULL, + `reply_to` VARCHAR(190) DEFAULT NULL, + `subject` VARCHAR(255) DEFAULT NULL, + `body` LONGTEXT NOT NULL, + `alt_body` TEXT DEFAULT NULL, + `context_type` VARCHAR(50) DEFAULT NULL, + `context_id` INT(11) DEFAULT NULL, + `scheduled_for` DATETIME DEFAULT NULL, + `attempts` INT(11) NOT NULL DEFAULT 0, + `max_attempts` INT(11) NOT NULL DEFAULT 3, + `next_attempt_at` DATETIME DEFAULT NULL, + `last_error` TEXT DEFAULT NULL, + `created_by` INT(11) DEFAULT NULL, + `sent_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + -- The drainer's hot query: pending/failed rows whose turn has come. + KEY `idx_mq_drain` (`status`, `next_attempt_at`, `scheduled_for`), + KEY `idx_mq_created` (`created_at`), + KEY `idx_mq_context` (`context_type`, `context_id`), + KEY `idx_mq_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- No foreign key on to_user_id / created_by: messages outlive the accounts they +-- were sent to, and the delivery history is worth more than the referential +-- tidiness. The Monitor page tolerates a missing user. diff --git a/includes/header.php b/includes/header.php index 9d60274..897c448 100644 --- a/includes/header.php +++ b/includes/header.php @@ -154,6 +154,14 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; Backups + +