Files
fitme3070.pt/api/delete_hub-content.php
T
andreandClaude Opus 5.5 895b3d58d7 fix(segurança): lixeira de 30 dias, CSRF em todas as ações, IP real, sessão e uploads
Correções da auditoria de 24/09 (audits/fitme3070-auditoria-2026-09-24.html).

Lixeira
- Tudo o que é importante passa a soft delete (deleted_at + deleted_by):
  equipa, modalidades, eventos, mensalidades, galerias, imagens, horário,
  horas de funcionamento, inscrições em eventos e pedidos de inscrição.
- config/functions_trash.php: um registo por tipo; apagar, restaurar, listar
  e eliminar leem todos dele. Eliminar de vez só com 30 dias na lixeira —
  a condição está no WHERE do DELETE, um POST forjado não apaga nada.
- Gestão → Lixeira (views/admin/trash/index.php): lista única com quem e
  quando, restauro a qualquer momento.
- Imagens de galeria: o site lista a pasta, por isso o ficheiro passa para
  .trash/ (negado pelo .htaccess) e volta no restauro.
- As listagens e o site público filtram deleted_at (horário também filtra
  modalidades apagadas). Migração: database/migration_20260925_trash.sql
  (só acrescenta colunas; tem de ser aplicada antes deste código).

CSRF
- csrf_check() recusa qualquer pedido que não seja POST (405). Antes saía
  sem verificar nada em GET, e dez ações de apagar/restaurar eram GET.
- Os dez handlers passaram a POST com token; links viraram formulários.

Autenticação e sessão
- client_ip(): X-Real-IP / último X-Forwarded-For, só vindo de proxy privado
  com TRUSTED_PROXY. O throttle contava o IP do proxy (limite global).
- Throttle também por conta (hash do email), além do IP.
- Sem oráculo de tempo: login verifica contra um hash de referência quando
  a conta não existe; a recuperação deixa o email na fila.
- Cookie de sessão Secure/HttpOnly/SameSite=Lax e use_strict_mode.
- Definições de sistema só para sysadmin; autocomplete=username.

Servidor e uploads
- .htaccess: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy,
  CSP (frame-ancestors, base-uri, object-src, form-action); nega parciais
  _*.php, PHP soltos do platform-tools e *.md; 404 própria.
- Uploads validados pelo conteúdo (finfo/getimagesize); SVG só sem scripts,
  handlers on*=, foreignObject ou hrefs externos.

Testado numa cópia da BD de produção (fitme_audit_test) com um contentor
temporário: 34/34 verificações HTTP ponta-a-ponta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:16:10 +01:00

147 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// Content endpoint for the id.andmore.pt / hub social-post generator (hub pull).
//
// Server-to-server, no session: the hub signs each request with the shared
// HUB_CONTENT_SECRET (.env; generated on id.andmore.pt → Plataformas →
// fitme3070 → "Gerar segredo de conteúdo"):
// X-Hub-Date: ISO-8601 timestamp (±10 min tolerance)
// X-Hub-Token: hash_hmac('sha256', date . path, secret)
//
// Response is the generic hub envelope. One item type:
// event — the weekly class schedule (dias da semana + as aulas nesses dias).
// Each recurring class is emitted as an event whose date_start is the
// next occurrence of that weekday/time, so the hub renders the day
// name automatically; the weekday is also given explicitly in fields.
//
// GET params: from=Y-m-d to=Y-m-d type=event (all optional; the schedule is
// recurring, so the date range is not used to filter).
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
header('Content-Type: application/json; charset=utf-8');
$secret = (string) env('HUB_CONTENT_SECRET', '');
if ($secret === '') {
http_response_code(503);
echo json_encode(['error' => 'HUB_CONTENT_SECRET não configurado nesta plataforma.']);
exit;
}
$date = (string) ($_SERVER['HTTP_X_HUB_DATE'] ?? '');
$token = (string) ($_SERVER['HTTP_X_HUB_TOKEN'] ?? '');
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
$ts = $date !== '' ? strtotime($date) : false;
if ($token === '' || $ts === false || abs(time() - $ts) > 600
|| !hash_equals(hash_hmac('sha256', $date . $path, $secret), $token)) {
http_response_code(403);
echo json_encode(['error' => 'Assinatura inválida.']);
exit;
}
session_write_close(); // not needed past auth; release the session lock
$type = (string) ($_GET['type'] ?? '');
// Optional category filter: ?category=segunda or ?categories=segunda,quarta.
// For this platform the category dimension is the weekday. Empty = all.
$cat_filter = array_filter(array_map(
fn($c) => strtolower(trim($c)),
preg_split('/[,]/', (string) ($_GET['categories'] ?? $_GET['category'] ?? ''))
));
// Weekday enum → display label and ISO day-of-week (1=Mon … 7=Sun).
$WEEKDAYS = [
'segunda' => ['Segunda-feira', 1],
'terça' => ['Terça-feira', 2],
'quarta' => ['Quarta-feira', 3],
'quinta' => ['Quinta-feira', 4],
'sexta' => ['Sexta-feira', 5],
'sábado' => ['Sábado', 6],
'domingo' => ['Domingo', 7],
];
$LOCALS = ['sala1' => 'Sala 1', 'sala2' => 'Sala 2', 'crossfit' => 'CrossFit'];
// Next occurrence (today or in the coming week) of a given weekday at $time.
$next_occurrence = static function (int $isoDow, string $time): DateTime {
$dt = new DateTime('today');
$diff = ($isoDow - (int) $dt->format('N') + 7) % 7; // 0 = today
if ($diff > 0) $dt->modify("+{$diff} day");
[$h, $m, $s] = array_pad(explode(':', $time), 3, '0');
$dt->setTime((int) $h, (int) $m, (int) $s);
return $dt;
};
$items = [];
// ── event: weekly class schedule ────────────────────────────────────────────
if ($type === '' || $type === 'event') {
// db_horario_list() already joins modalidades and orders segunda→domingo,
// then by start time; restrict to live modalidades (not soft-deleted).
$rows = $pdo->query("
SELECT h.id, h.dia_semana, h.local,
m.nome AS modalidade_nome,
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio_fmt,
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL
WHERE h.deleted_at IS NULL
ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
h.hora_inicio ASC
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$dia = (string) $r['dia_semana'];
if (!isset($WEEKDAYS[$dia])) continue; // unknown / NULL weekday — skip
if ($cat_filter && !in_array($dia, $cat_filter, true)) continue; // category filter
[$diaLabel, $isoDow] = $WEEKDAYS[$dia];
$ini = (string) $r['hora_inicio_fmt'];
$fim = (string) $r['hora_fim_fmt'];
$hora = $fim !== '' ? ($ini . '–' . $fim) : $ini;
$local = $LOCALS[(string) $r['local']] ?? '';
$start = $next_occurrence($isoDow, $ini !== '' ? $ini . ':00' : '00:00:00');
$end = $fim !== '' ? $next_occurrence($isoDow, $fim . ':00') : null;
$fields = ['Dia' => $diaLabel, 'Hora' => $hora];
if ($local !== '') $fields['Local'] = $local;
$items[] = [
'id' => 'aula-' . (int) $r['id'],
'type' => 'event',
'title' => (string) $r['modalidade_nome'],
'subtitle' => $hora . ($local !== '' ? ' · ' . $local : ''),
'date_start' => $start->format('c'),
'date_end' => $end ? $end->format('c') : '',
'url' => base_url('home/horario'),
'category' => $dia, // machine key (weekday enum value)
'category_label' => $diaLabel, // human label
'fields' => $fields,
'media' => [],
];
}
}
// Distinct categories present, in item order (segunda→domingo), with counts —
// lets the hub offer a category filter without re-deriving it.
$categories = [];
foreach ($items as $it) {
$k = $it['category'];
if ($k === '') continue;
if (!isset($categories[$k])) {
$categories[$k] = ['key' => $k, 'label' => $it['category_label'], 'count' => 0];
}
$categories[$k]['count']++;
}
echo json_encode([
'app' => 'fitme3070',
'generated_at' => gmdate('c'),
'item_types' => array_values(array_unique(array_column($items, 'type'))),
'category_label'=> 'Dia da semana', // what the category dimension means here
'categories' => array_values($categories),
'items' => $items,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);