Files
fitme3070.pt/cron.php
T
andreandClaude Opus 5.5 b202262da0 feat(rgpd): política de privacidade e termos, mapa a pedido, fotos locais, retenção, tema claro/escuro
- Política de privacidade reescrita: tabela de dados/finalidades/fundamento
  (inclui os pedidos de aula, inscrição e eventos com IP), prazos concretos,
  subcontratantes, Google Maps e reviews, cookies. Termos e condições novos
  (/home/termos), no rodapé e no sitemap. Campos [ENTRE PARÊNTESES RETOS]
  e revisão por jurista pendentes antes de publicar no domínio real.
- Google Maps só carrega ao carregar em «Mostrar mapa» (map_click_to_load);
  as fotos dos autores das reviews são guardadas pelo servidor em
  uploads/reviews/ e servidas localmente (review_avatar_url).
- Retenção que a política declara, aplicada pelo cron (04:00): leads com
  mais de 12 meses → lixeira; blocked_submissions e outbox de segurança
  apagados aos 90 dias (trash_retention_sweep, auditado).
- Backoffice e login com tema claro/escuro/automático: data-bs-theme
  aplicado no <head> antes do CSS (sem flash), escolha em localStorage,
  segue o sistema em tempo real, color-scheme e theme-color para os dois.
  Cores próprias passaram a variáveis do Bootstrap. lang="pt-PT".

Testado numa cópia da BD de produção: 72/72 verificações.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:39:05 +01:00

108 lines
5.1 KiB
PHP

<?php
// ─────────────────────────────────────────────────────────────────────────────
// Scheduled maintenance runner. Designed to be fired every 5 minutes.
//
// Shell cron (preferred — no token, no web exposure):
// */5 * * * * /usr/bin/php /path/to/fitme3070.pt/cron.php >/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=<CRON_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)");
// Leads > 12 meses → lixeira; registos de segurança > 90 dias.
foreach (trash_retention_sweep($pdo) as $what => $n) cron_log("retenção: {$n} em {$what}");
} 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);