Initial import of fitme3070.pt platform

Plain-PHP fleet platform with id.andmore.pt SSO. Wires shared/platform-tools
as a submodule (platform_update, backups, git/migration tools) like the other
platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 4.8 committed 2026-06-12 10:44:58 +01:00
commit e68d357177
2330 files changed
+215968

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
<?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_JPEG_QUALITY = 82;
const IMG_WEBP_QUALITY = 80;
// 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';
}