Files
fitme3070.pt/config/functions_images.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

208 lines
9.3 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
// ─── Image optimization helpers ────────────────────────────────────────────────
// Resizes raster images IN PLACE so the longest side fits the per-area cap and
// re-encodes at web quality. Called on every upload (galerias, team,
// modalidades) and by the "Otimizar" buttons in the backoffice, which run the
// same pass over files uploaded before this existed.
//
// Idempotent by design: images already within the cap are left untouched, so
// the bulk buttons can be pressed repeatedly without generation loss.
// SVGs and animated GIFs are skipped (GD would rasterize/flatten them).
// Requires the GD extension; degrades to a no-op (status "failed") without it,
// so an upload never breaks because optimization couldn't run.
// Caps per area — longest side, in pixels.
const IMG_MAX_GALLERY = 1920;
const IMG_MAX_TEAM = 512;
const IMG_MAX_MODALIDADE = 256;
const IMG_MAX_EVENT = 1600;
const IMG_JPEG_QUALITY = 82;
const IMG_WEBP_QUALITY = 80;
// ─── Validação de uploads ────────────────────────────────────────────────────
// A extensão vem do nome que o browser manda e não prova nada: um PHP chamado
// foto.jpg passava. Aqui decide-se pelo conteúdo (finfo, com getimagesize como
// segunda opinião) e devolve-se a extensão CERTA para gravar, ou null.
//
// SVG é texto e pode levar <script>: só passa se a lista o permitir e se
// svg_is_safe() não encontrar nada executável. Aberto diretamente no browser,
// um SVG corre no domínio do site.
function upload_image_ext(array $file, array $allowed): ?string {
$tmp = (string) ($file['tmp_name'] ?? '');
if ($tmp === '' || !is_uploaded_file($tmp)) return null;
$mime = '';
if (class_exists('finfo')) {
$mime = (string) (new finfo(FILEINFO_MIME_TYPE))->file($tmp);
}
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
if (isset($raster[$mime]) || $mime === '') {
$info = @getimagesize($tmp);
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
if ($ext === null) return null;
if ($ext === 'jpg' && !in_array('jpg', $allowed, true) && in_array('jpeg', $allowed, true)) $ext = 'jpeg';
return in_array($ext, $allowed, true) ? $ext : null;
}
// SVG: o finfo diz image/svg+xml, text/xml ou text/plain conforme o ficheiro.
if (in_array('svg', $allowed, true) && in_array($mime, ['image/svg+xml', 'text/xml', 'application/xml', 'text/plain', 'text/html'], true)) {
$svg = (string) @file_get_contents($tmp, false, null, 0, 2 * 1024 * 1024);
if (stripos($svg, '<svg') === false) return null;
return svg_is_safe($svg) ? 'svg' : null;
}
return null;
}
// Recusa tudo o que num SVG executa código ou vai buscar coisas fora:
// <script>, handlers on*=, javascript:, <foreignObject> (HTML embutido),
// iframes/objetos, entidades XML e href/xlink:href que não sejam âncoras
// internas (#id) nem imagens data:.
function svg_is_safe(string $svg): bool {
$bad = '/<\s*script|<\s*foreignObject|<\s*(iframe|embed|object)\b|\bon[a-z]+\s*=|javascript\s*:|<!ENTITY|<!DOCTYPE[^>]*\[/i';
if (preg_match($bad, $svg)) return false;
if (preg_match_all('/\b(?:xlink:)?href\s*=\s*["\']\s*([^"\']*)/i', $svg, $m)) {
foreach ($m[1] as $href) {
if ($href === '' || $href[0] === '#' || stripos($href, 'data:image/') === 0) continue;
return false;
}
}
return true;
}
// Resize one file in place. Returns ['status' => 'resized'|'skipped'|'failed',
// 'saved' => bytes freed (0 unless resized), 'reason' => short PT label].
function img_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
$info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
[$w, $h] = $info;
$type = $info[2];
$supported = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF];
if (function_exists('imagecreatefromwebp')) $supported[] = IMAGETYPE_WEBP;
if (!in_array($type, $supported, true)) return $skip('formato não suportado');
// GIFs animados perdiam a animação no GD — não tocar.
if ($type === IMAGETYPE_GIF
&& substr_count((string) file_get_contents($path), "\x21\xF9\x04") > 1) {
return $skip('GIF animado');
}
// Orientação EXIF: fotos de telemóvel guardadas "deitadas" contam para o
// lado maior depois de rodadas, por isso resolve-se antes do teste de tamanho.
$rotation = 0;
if ($type === IMAGETYPE_JPEG && function_exists('exif_read_data')) {
$orientation = (int) (@exif_read_data($path)['Orientation'] ?? 1);
$rotation = [3 => 180, 6 => -90, 8 => 90][$orientation] ?? 0;
if (abs($rotation) === 90) [$w, $h] = [$h, $w];
}
if ($w <= $max_dim && $h <= $max_dim && $rotation === 0) {
return $skip('já dentro do limite');
}
// Imagens grandes podem exceder o memory_limit ao descodificar.
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
default => false,
};
if (!$src) return $fail('imagem ilegível');
if ($rotation !== 0) {
$rotated = imagerotate($src, $rotation, 0);
if ($rotated) { imagedestroy($src); $src = $rotated; }
$w = imagesx($src); $h = imagesy($src);
}
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
$dst = imagecreatetruecolor($nw, $nh);
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
} elseif ($type === IMAGETYPE_GIF) {
$tidx = imagecolortransparent($src);
if ($tidx >= 0 && $tidx < imagecolorstotal($src)) {
$t = imagecolorsforindex($src, $tidx);
$newt = imagecolorallocate($dst, $t['red'], $t['green'], $t['blue']);
imagefilledrectangle($dst, 0, 0, $nw, $nh, $newt);
imagecolortransparent($dst, $newt);
}
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$tmp = $path . '.opt.tmp';
if ($type === IMAGETYPE_JPEG) imageinterlace($dst, true); // JPEG progressivo
$ok = match ($type) {
IMAGETYPE_JPEG => imagejpeg($dst, $tmp, IMG_JPEG_QUALITY),
IMAGETYPE_PNG => imagepng($dst, $tmp, 9),
IMAGETYPE_GIF => imagegif($dst, $tmp),
IMAGETYPE_WEBP => imagewebp($dst, $tmp, IMG_WEBP_QUALITY),
default => false,
};
imagedestroy($dst);
if (!$ok || !is_file($tmp)) { @unlink($tmp); return $fail('falha ao gravar'); }
$before = (int) filesize($path);
$after = (int) filesize($tmp);
if (!@rename($tmp, $path)) { // alguns FS não substituem no rename
@unlink($path);
if (!@rename($tmp, $path)) { @unlink($tmp); return $fail('falha ao substituir'); }
}
return ['status' => 'resized', 'saved' => max(0, $before - $after), 'reason' => "{$w}×{$h} → {$nw}×{$nh}"];
}
// Run img_optimize() over every raster file directly inside $dir (no recursion).
// Returns aggregate counters: resized / skipped / failed / saved (bytes).
function img_optimize_dir(string $dir, int $max_dim): array {
@set_time_limit(0);
$stats = ['resized' => 0, 'skipped' => 0, 'failed' => 0, 'saved' => 0];
if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue;
$r = img_optimize($path, $max_dim);
$stats[$r['status']]++;
$stats['saved'] += $r['saved'];
}
return $stats;
}
// PT-PT flash message for the bulk buttons.
function img_stats_message(array $s): string {
$parts = [];
if ($s['resized']) {
$msg = $s['resized'] . ' imagem(ns) redimensionada(s)';
if ($s['saved'] > 0) $msg .= ' — poupados ' . img_human_bytes($s['saved']);
$parts[] = $msg;
}
if ($s['skipped']) $parts[] = $s['skipped'] . ' já dentro do limite ou não aplicável';
if ($s['failed']) $parts[] = $s['failed'] . ' com falha';
return $parts ? 'Otimização concluída: ' . implode(', ', $parts) . '.' : 'Nenhuma imagem encontrada.';
}
function img_human_bytes(int $bytes): string {
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '') . ' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B';
}