d8376d6cdf
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 16s
Generic: Project CI / Lint & Validate (pull_request) Successful in 48s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 52s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
#95 — ACL + Options: - Add access.xml (core actions + mokoog.batch / mokoog.import custom actions) - Add config.xml (Permissions tab + note pointing settings to the system plugin) - Declare both in the manifest; Batch/ImportExport controllers now check the custom actions with a fallback to the prior core checks (no lockout) #103 — CSV import is now reachable: - Add an Import toolbar button that toggles a multipart file-upload form (jform[csv_file]) posting to importexport.import with a CSRF token #104 — Dead code + disk leak: - Delete unused ImageGenerator class and JsonLdBuilder::buildOrganization() - Add ImageHelper::pruneOldFiles() (deletes generated images older than 30d) and call it on content save so the generated-image cache is bounded #107 — Packaging: - Declare language/en-US in the component manifest (was never installed) - Remove undeclared empty stub dirs src/Field, src/Service
398 lines
12 KiB
PHP
398 lines
12 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteOpenGraph
|
|
* @subpackage plg_system_mokoog
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Joomla\Plugin\System\MokoOG\Helper;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\Filesystem\File;
|
|
use Joomla\Filesystem\Folder;
|
|
use Joomla\CMS\Log\Log;
|
|
|
|
class ImageHelper
|
|
{
|
|
/**
|
|
* Target width for OG images (Facebook recommended).
|
|
*/
|
|
private const TARGET_WIDTH = 1200;
|
|
|
|
/**
|
|
* Target height for OG images (Facebook recommended).
|
|
*/
|
|
private const TARGET_HEIGHT = 630;
|
|
|
|
/**
|
|
* JPEG quality for generated images.
|
|
*/
|
|
private const JPEG_QUALITY = 85;
|
|
|
|
/**
|
|
* Output directory relative to JPATH_ROOT.
|
|
*/
|
|
private const OUTPUT_DIR = 'images/mokoog/generated';
|
|
|
|
/**
|
|
* Resize an image to OG-optimized dimensions if needed.
|
|
*
|
|
* Returns the path to the resized image relative to JPATH_ROOT,
|
|
* or the original path if no resize was needed or possible.
|
|
*
|
|
* @param string $imagePath Image path relative to JPATH_ROOT
|
|
* @param int $targetWidth Target width (default 1200)
|
|
* @param int $targetHeight Target height (default 630)
|
|
* @param int $quality JPEG quality 1-100 (default 85)
|
|
*
|
|
* @return string Path to the output image (relative to JPATH_ROOT)
|
|
*/
|
|
public static function resize(
|
|
string $imagePath,
|
|
int $targetWidth = self::TARGET_WIDTH,
|
|
int $targetHeight = self::TARGET_HEIGHT,
|
|
int $quality = self::JPEG_QUALITY
|
|
): string {
|
|
// Resolve absolute path
|
|
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
|
|
|
if (!is_file($absPath)) {
|
|
return $imagePath;
|
|
}
|
|
|
|
$imageInfo = getimagesize($absPath);
|
|
|
|
if (!$imageInfo) {
|
|
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
|
|
|
|
return $imagePath;
|
|
}
|
|
|
|
[$origWidth, $origHeight, $type] = $imageInfo;
|
|
|
|
// Skip if already at or below target size
|
|
if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) {
|
|
return $imagePath;
|
|
}
|
|
|
|
// Ensure output directory exists
|
|
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
|
|
|
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
|
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
|
|
|
|
return $imagePath;
|
|
}
|
|
|
|
// Generate output filename based on source hash + dimensions
|
|
$hash = md5($imagePath . $targetWidth . $targetHeight);
|
|
$outputName = $hash . '.jpg';
|
|
$outputPath = $outputDir . '/' . $outputName;
|
|
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
|
|
|
|
// Skip if already generated
|
|
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
|
|
return $outputRel;
|
|
}
|
|
|
|
// Load source image
|
|
$source = self::loadImage($absPath, $type);
|
|
|
|
if (!$source) {
|
|
return $imagePath;
|
|
}
|
|
|
|
// Calculate crop dimensions (center crop to target aspect ratio)
|
|
$targetRatio = $targetWidth / $targetHeight;
|
|
$sourceRatio = $origWidth / $origHeight;
|
|
|
|
if ($sourceRatio > $targetRatio) {
|
|
// Source is wider — crop sides
|
|
$cropHeight = $origHeight;
|
|
$cropWidth = (int) round($origHeight * $targetRatio);
|
|
$cropX = (int) round(($origWidth - $cropWidth) / 2);
|
|
$cropY = 0;
|
|
} else {
|
|
// Source is taller — crop top/bottom
|
|
$cropWidth = $origWidth;
|
|
$cropHeight = (int) round($origWidth / $targetRatio);
|
|
$cropX = 0;
|
|
$cropY = (int) round(($origHeight - $cropHeight) / 2);
|
|
}
|
|
|
|
// Create output canvas and resample
|
|
$output = imagecreatetruecolor($targetWidth, $targetHeight);
|
|
|
|
imagecopyresampled(
|
|
$output,
|
|
$source,
|
|
0,
|
|
0,
|
|
$cropX,
|
|
$cropY,
|
|
$targetWidth,
|
|
$targetHeight,
|
|
$cropWidth,
|
|
$cropHeight
|
|
);
|
|
|
|
// Save as JPEG
|
|
imagejpeg($output, $outputPath, $quality);
|
|
|
|
imagedestroy($source);
|
|
imagedestroy($output);
|
|
|
|
return $outputRel;
|
|
}
|
|
|
|
/**
|
|
* Resize an image for a specific platform.
|
|
*
|
|
* @param string $imagePath Relative image path
|
|
* @param string $platform Platform name (facebook, twitter, pinterest, whatsapp)
|
|
*
|
|
* @return string Path to the resized image
|
|
*/
|
|
public static function resizeForPlatform(string $imagePath, string $platform): string
|
|
{
|
|
$sizes = [
|
|
'facebook' => ['width' => 1200, 'height' => 630],
|
|
'twitter' => ['width' => 1200, 'height' => 600],
|
|
'pinterest' => ['width' => 1000, 'height' => 1500],
|
|
'whatsapp' => ['width' => 400, 'height' => 400],
|
|
];
|
|
|
|
if (!isset($sizes[$platform])) {
|
|
return self::resize($imagePath);
|
|
}
|
|
|
|
$size = $sizes[$platform];
|
|
|
|
return self::resizeToSize($imagePath, $size['width'], $size['height'], $platform);
|
|
}
|
|
|
|
/**
|
|
* Resize an image to specific dimensions with a platform-specific subdirectory.
|
|
*
|
|
* @param string $imagePath Image path relative to JPATH_ROOT
|
|
* @param int $width Target width
|
|
* @param int $height Target height
|
|
* @param string $subdir Subdirectory name for output (e.g. platform name)
|
|
*
|
|
* @return string Path to the output image (relative to JPATH_ROOT)
|
|
*/
|
|
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): string
|
|
{
|
|
// Resolve absolute path
|
|
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
|
|
|
if (!is_file($absPath)) {
|
|
return $imagePath;
|
|
}
|
|
|
|
$imageInfo = getimagesize($absPath);
|
|
|
|
if (!$imageInfo) {
|
|
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
|
|
|
|
return $imagePath;
|
|
}
|
|
|
|
[$origWidth, $origHeight, $type] = $imageInfo;
|
|
|
|
// Skip if already at or below target size
|
|
if ($origWidth <= $width && $origHeight <= $height) {
|
|
return $imagePath;
|
|
}
|
|
|
|
// Build output directory with optional subdirectory
|
|
$outputRelDir = self::OUTPUT_DIR . ($subdir ? '/' . $subdir : '');
|
|
$outputDir = JPATH_ROOT . '/' . $outputRelDir;
|
|
|
|
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
|
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . $outputRelDir, Log::WARNING, 'mokoog');
|
|
|
|
return $imagePath;
|
|
}
|
|
|
|
// Generate output filename based on source hash + dimensions
|
|
$hash = md5($imagePath . $width . $height);
|
|
$outputName = $hash . '.jpg';
|
|
$outputPath = $outputDir . '/' . $outputName;
|
|
$outputRel = $outputRelDir . '/' . $outputName;
|
|
|
|
// Skip if already generated
|
|
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
|
|
return $outputRel;
|
|
}
|
|
|
|
// Load source image
|
|
$source = self::loadImage($absPath, $type);
|
|
|
|
if (!$source) {
|
|
return $imagePath;
|
|
}
|
|
|
|
// Calculate crop dimensions (center crop to target aspect ratio)
|
|
$targetRatio = $width / $height;
|
|
$sourceRatio = $origWidth / $origHeight;
|
|
|
|
if ($sourceRatio > $targetRatio) {
|
|
// Source is wider — crop sides
|
|
$cropHeight = $origHeight;
|
|
$cropWidth = (int) round($origHeight * $targetRatio);
|
|
$cropX = (int) round(($origWidth - $cropWidth) / 2);
|
|
$cropY = 0;
|
|
} else {
|
|
// Source is taller — crop top/bottom
|
|
$cropWidth = $origWidth;
|
|
$cropHeight = (int) round($origWidth / $targetRatio);
|
|
$cropX = 0;
|
|
$cropY = (int) round(($origHeight - $cropHeight) / 2);
|
|
}
|
|
|
|
// Create output canvas and resample
|
|
$output = imagecreatetruecolor($width, $height);
|
|
|
|
imagecopyresampled(
|
|
$output,
|
|
$source,
|
|
0,
|
|
0,
|
|
$cropX,
|
|
$cropY,
|
|
$width,
|
|
$height,
|
|
$cropWidth,
|
|
$cropHeight
|
|
);
|
|
|
|
// Save as JPEG
|
|
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
|
|
|
|
imagedestroy($source);
|
|
imagedestroy($output);
|
|
|
|
return $outputRel;
|
|
}
|
|
|
|
/**
|
|
* Remove a generated image file.
|
|
*
|
|
* @param string $generatedPath Path relative to JPATH_ROOT
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function cleanup(string $generatedPath): void
|
|
{
|
|
if (empty($generatedPath) || !str_starts_with($generatedPath, self::OUTPUT_DIR)) {
|
|
return;
|
|
}
|
|
|
|
$absPath = JPATH_ROOT . '/' . $generatedPath;
|
|
|
|
if (is_file($absPath)) {
|
|
File::delete($absPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prune generated images older than the given age, to bound disk usage.
|
|
*
|
|
* The generated-image cache is never otherwise cleaned, so without this it
|
|
* grows unbounded over time.
|
|
*
|
|
* @param int $maxAgeDays Delete generated files older than this (default 30)
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function pruneOldFiles(int $maxAgeDays = 30): void
|
|
{
|
|
$dir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
|
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$cutoff = time() - ($maxAgeDays * 86400);
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile()
|
|
&& $file->getFilename() !== 'index.html'
|
|
&& $file->getMTime() < $cutoff) {
|
|
File::delete($file->getPathname());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an image meets minimum OG size requirements.
|
|
*
|
|
* @param string $imagePath Image path relative to JPATH_ROOT
|
|
*
|
|
* @return array{valid: bool, width: int, height: int, message: string}
|
|
*/
|
|
public static function validate(string $imagePath): array
|
|
{
|
|
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
|
|
|
if (!is_file($absPath)) {
|
|
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
|
|
}
|
|
|
|
$imageInfo = getimagesize($absPath);
|
|
|
|
if (!$imageInfo) {
|
|
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
|
|
}
|
|
|
|
[$width, $height] = $imageInfo;
|
|
|
|
// Facebook minimum: 200x200, recommended: 1200x630
|
|
// WhatsApp minimum: 300x200
|
|
if ($width < 200 || $height < 200) {
|
|
return [
|
|
'valid' => false,
|
|
'width' => $width,
|
|
'height' => $height,
|
|
'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.",
|
|
];
|
|
}
|
|
|
|
return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK'];
|
|
}
|
|
|
|
/**
|
|
* Load an image resource from a file.
|
|
*
|
|
* @param string $path Absolute file path
|
|
* @param int $type IMAGETYPE_* constant
|
|
*
|
|
* @return \GdImage|false
|
|
*/
|
|
private static function loadImage(string $path, int $type)
|
|
{
|
|
$image = match ($type) {
|
|
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
|
|
IMAGETYPE_PNG => imagecreatefrompng($path),
|
|
IMAGETYPE_GIF => imagecreatefromgif($path),
|
|
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : false,
|
|
default => false,
|
|
};
|
|
|
|
if (!$image) {
|
|
Log::add('MokoOG ImageHelper: Failed to load image: ' . basename($path), Log::WARNING, 'mokoog');
|
|
}
|
|
|
|
return $image;
|
|
}
|
|
}
|