🐘 Plugin: cleanUpload
<?php
/*
* ------------------------------------------------------------
* V 2025-12-22
*
* cleanUpload ist ein MODX Revolution FileManager-Plugin für Uploads über den Media Browser.
* Es bereinigt und optimiert Dateien; JPEG- und PDF-Metadaten werden entfernt, DSGVO-konform.
*
* cleanUpload is a MODX Revolution FileManager plugin for uploads via the Media Browser.
* It cleans and optimizes files; JPEG and PDF metadata are removed, GDPR-compliant.
*
* ------------------------------------------------------------
* REMARKS / KURZÜBERBLICK
* – Bei Dateinamen-Kollisionen wird die bestehende Datei vor dem Upload auf _1, _2, … umbenannt.
* On name collision, the existing file is renamed to _1, _2, … before the upload proceeds.
* – Dateinamen werden bereinigt; Groß-/Kleinschreibung der Extension wird vereinheitlicht.
* Filenames are cleaned; extension case is normalized.
* – JPEG/PNG/GIF werden bei Bedarf proportional verkleinert; PNG/GIF behalten Transparenz,
* animierte GIFs werden übersprungen.
* JPEG/PNG/GIF are downscaled if needed; PNG/GIF keep transparency, animated GIFs are skipped.
* – PDFs werden via pdf2ps/ps2pdf neu geschrieben (Metadaten entfernt).
* PDFs are re-written using pdf2ps/ps2pdf (metadata removed).
* – Admin-Bypass (mgr) und Schalter für Bild-/PDF-Optimierung sind vorhanden.
* Admin bypass (mgr) and switches for image/PDF optimization are available.
*
* ------------------------------------------------------------
* WICHTIG: Für cleanUpload müssen in MODX die beiden System-Events aktiviert sein:
* OnFileManagerBeforeUpload und OnFileManagerUpload
* IMPORTANT: The following two MODX system events must be enabled:
* OnFileManagerBeforeUpload and OnFileManagerUpload
*
* Seit MODX 3: Die Systemeinstellung „upload_translit“ muss deaktiviert sein,
* da cleanUpload eine eigene Transliteration verwendet.
* Since MODX 3: The System Settings “upload_translit” must be disabled,
* because cleanUpload uses its own transliteration.
*
* ------------------------------------------------------------
* Tested with MODX 2.8.8 (PHP 8.2.27), 3.1.0 (PHP 8.3.14) and 3.1.2 (PHP 8.4.12)
*
* ------------------------------------------------------------
* Reference and inspiration:
* https://www.php.net/manual/en/function.image-type-to-extension.php
* https://forums.modx.com/?action=thread&thread=73940&page=2
*
* Dependencies for PDF processing on the server:
* - pdf2ps (Poppler)
* - ps2pdf (Ghostscript)
* ------------------------------------------------------------
*/
use MODX\Revolution\modX;
/* =========================
PDF-Verarbeitung: Timing
========================= */
if (!defined('PDF_PROCESSING_WAIT')) {
define('PDF_PROCESSING_WAIT', 5); // Maximale Wartezeit (Sekunden)
}
if (!defined('PDF_PROCESSING_ATTEMPTS')) {
define('PDF_PROCESSING_ATTEMPTS', 3); // Maximale Versuche für PDF-Verarbeitung
}
/* =========================
Settings / Schalter
========================= */
$enableImageOptimize = true; // Bilder (JPEG/PNG/GIF) skalieren/speichern
$enablePdfOptimize = true; // PDFs via pdf2ps/ps2pdf neu schreiben
$adminBypass = false; // Wenn true: im Manager-Kontext (mgr) keine Normierung/Optimierung
$maxWidth = 1280; // Maximale Pixelbreite
$maxHeight = 1280; // Maximale Pixelhöhe
$quality = 80; // JPEG-Qualität in % (wird für PNG in Kompressionslevel umgerechnet)
$slug = '_'; // Ersetzungszeichen
global $modx;
$eventName = $modx->event->name;
/* =========================
Admin-Check (Bypass)
========================= */
$isManager = false;
try {
$isManager = ($modx && $modx->user && $modx->user->hasSessionContext('mgr'));
} catch (Throwable $t) {
// Ignorieren, fallback bleibt false
}
/* =========================
GD-Erweiterung prüfen
========================= */
if (!extension_loaded('gd') && !extension_loaded('gd2')) {
$modx->log(modX::LOG_LEVEL_ERROR, '[cleanUpload] Error: GD extension not loaded');
return false;
}
/* =========================
Cleaning filename function
========================= */
if (!function_exists('cleanFilename')) {
function cleanFilename($modx, $filename, $slug) {
$filename = trim(
preg_replace('~[^a-zA-Z0-9äöüÄÖÜß-]+~i', $slug, $filename),
$slug
);
if (empty($filename)) {
return false;
}
return $filename;
}
}
/* =========================
Helfer: animiertes GIF erkennen (mehrere Frames)
========================= */
if (!function_exists('isAnimatedGif')) {
function isAnimatedGif($filePath) {
$fh = @fopen($filePath, 'rb');
if (!$fh) return false;
$count = 0;
while (!feof($fh) && $count < 2) {
$chunk = fread($fh, 1024 * 64);
$count += preg_match_all('#\x00\x21\xF9\x04#', $chunk, $m);
}
fclose($fh);
return ($count > 1);
}
}
/* =========================
Resize function (JPEG/PNG/GIF mit Transparenz-Erhalt)
========================= */
if (!function_exists('imgResize')) {
function imgResize($modx, $source, $target, $maxWidth, $maxHeight, $quality) {
$info = @getimagesize($source);
if ($info === false) { return false; }
[$source_width, $source_height, $source_type] = $info;
// GIF: animierte GIFs nicht anfassen
if ($source_type === IMAGETYPE_GIF && isAnimatedGif($source)) {
$modx->log(modX::LOG_LEVEL_INFO, '[cleanUpload] Skip animated GIF: ' . $source);
return true; // keine Fehlermeldung, nur überspringen
}
// Quelle laden
$source_gd_image = match ($source_type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($source),
IMAGETYPE_PNG => @imagecreatefrompng($source),
IMAGETYPE_GIF => @imagecreatefromgif($source),
default => false,
};
if ($source_gd_image === false) { return false; }
// Zielgröße proportional berechnen (lange Seite begrenzen)
if ($source_width <= $maxWidth && $source_height <= $maxHeight) {
$image_width = $source_width;
$image_height = $source_height;
} else {
$scale = min($maxWidth / $source_width, $maxHeight / $source_height);
$image_width = (int)round($source_width * $scale);
$image_height = (int)round($source_height * $scale);
}
// Wenn keine Verkleinerung nötig: früh beenden
if ($image_width === $source_width && $image_height === $source_height) {
imagedestroy($source_gd_image);
return true;
}
// Zielbild anlegen
$gd_image = imagecreatetruecolor($image_width, $image_height);
if (!$gd_image) { imagedestroy($source_gd_image); return false; }
// Transparenz-Handling je nach Typ
if ($source_type === IMAGETYPE_PNG) {
imagealphablending($gd_image, false);
imagesavealpha($gd_image, true);
$transparent = imagecolorallocatealpha($gd_image, 0, 0, 0, 127);
imagefilledrectangle($gd_image, 0, 0, $image_width, $image_height, $transparent);
} elseif ($source_type === IMAGETYPE_GIF) {
$transparentIndex = imagecolortransparent($source_gd_image);
if ($transparentIndex >= 0) {
$trn = imagecolorsforindex($source_gd_image, $transparentIndex);
$transparentColor = imagecolorallocate($gd_image, $trn['red'], $trn['green'], $trn['blue']);
imagefill($gd_image, 0, 0, $transparentColor);
imagecolortransparent($gd_image, $transparentColor);
} else {
$white = imagecolorallocate($gd_image, 255, 255, 255);
imagefill($gd_image, 0, 0, $white);
}
}
// Skalieren
imagecopyresampled(
$gd_image, $source_gd_image,
0, 0, 0, 0,
$image_width, $image_height,
$source_width, $source_height
);
// Speichern je nach Typ
$ok = false;
if ($source_type === IMAGETYPE_JPEG) {
$ok = imagejpeg($gd_image, $target, $quality);
} elseif ($source_type === IMAGETYPE_PNG) {
// quality 0–100 -> png compression 0–9 (inverse)
$pngCompression = max(1, (int)round((100 - max(0, min(100, $quality))) * 9 / 100));
$ok = imagepng($gd_image, $target, $pngCompression);
} elseif ($source_type === IMAGETYPE_GIF) {
$ok = imagegif($gd_image, $target);
}
imagedestroy($source_gd_image);
imagedestroy($gd_image);
return $ok;
}
}
/* =========================
Hauptlogik: Bilder/PDFs
========================= */
if (empty($files) || !is_array($files)) {
return true;
}
foreach ($files as &$file) {
try {
if ($file['error'] != 0) {
throw new Exception('[cleanUpload] Error during upload: ' . $file['error']);
}
$dir = $directory;
$fileDir = $directory . $file['name'];
$bases = $source->getBases($directory);
$fullPath = $bases['pathAbsolute'] . ltrim($directory, '/');
$pathInfo = pathinfo($file['name']);
$fileName = isset($pathInfo['filename']) ? $pathInfo['filename'] : '';
$fileExt = isset($pathInfo['extension']) ? ('.' . $pathInfo['extension']) : '';
$fileExtLow = strtolower($fileExt);
$fileNameNew = cleanFilename($modx, $fileName, $slug);
if ($fileNameNew === false) {
throw new Exception('[cleanUpload] Filename normalization failed.');
}
$fullNameNewLow = $fileNameNew . $fileExtLow;
$fullPathNameNew = $fullPath . $fullNameNewLow;
switch ($eventName) {
case 'OnFileManagerBeforeUpload':
// Bestehende Datei vor Upload umbenennen (Suffix _1, _2, …) – vermeidet MODX-Fehler
$bases = $source->getBases($directory);
$fullPath = $bases['pathAbsolute'] . ltrim($directory, '/');
$pathInfo = pathinfo($file['name']);
$fileExtLow = isset($pathInfo['extension']) ? ('.' . strtolower($pathInfo['extension'])) : '';
$fileNameNew = cleanFilename($modx, $pathInfo['filename'], $slug);
if ($fileNameNew === false) { break; }
$fullNameNewLow = $fileNameNew . $fileExtLow;
if (is_file($fullPath . $fullNameNewLow)) {
// bestehende Datei wegsichern: name_1.ext, name_2.ext, …
$i = 1;
do {
$existingCandidate = $fileNameNew . '_' . $i . $fileExtLow;
$i++;
} while (is_file($fullPath . $existingCandidate));
// vorhandene Datei im selben Verzeichnis umbenennen
$source->renameObject($directory . $fullNameNewLow, $existingCandidate);
}
break;
case 'OnFileManagerUpload':
if ($adminBypass && $isManager) {
break;
}
$needsRename = ($fileName != $fileNameNew) || ($fileExt != $fileExtLow);
if ($needsRename) {
$candidate = $fullNameNewLow;
while (file_exists($fullPath . $candidate)) {
$candidate = $fileNameNew . '_' . uniqid() . $fileExtLow;
}
if ($candidate !== $fullNameNewLow) {
$fullNameNewLow = $candidate;
$fullPathNameNew = $fullPath . $fullNameNewLow;
}
$source->renameObject($fileDir, $fullNameNewLow);
}
// Fallback-Kollision auch ohne Umbenennung
if (!$needsRename) {
$candidate = $fullNameNewLow;
while (file_exists($fullPath . $candidate) && ($dir . $candidate) !== $fileDir) {
$candidate = $fileNameNew . '_' . uniqid() . $fileExtLow;
}
if ($candidate !== $fullNameNewLow) {
$fullNameNewLow = $candidate;
$fullPathNameNew = $fullPath . $fullNameNewLow;
$source->renameObject($fileDir, $fullNameNewLow);
}
}
// Bild-Optimierung (JPEG/PNG/GIF)
if ($enableImageOptimize && ($fileExtLow === '.jpg' || $fileExtLow === '.jpeg' || $fileExtLow === '.png' || $fileExtLow === '.gif')) {
if (!imgResize($modx, $fullPathNameNew, $fullPathNameNew, $maxWidth, $maxHeight, $quality)) {
$modx->log(modX::LOG_LEVEL_ERROR, '[cleanUpload] Image resize failed for ' . $fullPathNameNew);
}
}
// PDF-Optimierung
if ($enablePdfOptimize && $fileExtLow === '.pdf') {
$exists = file_exists($fullPathNameNew);
if (!$exists) {
for ($attempt = 0; $attempt < PDF_PROCESSING_ATTEMPTS; $attempt++) {
if (file_exists($fullPathNameNew)) { $exists = true; break; }
sleep(PDF_PROCESSING_WAIT);
}
}
if (!$exists) {
throw new Exception('[cleanUpload] PDF not found after attempts.');
}
$hasPdf2ps = trim((string)@shell_exec('which pdf2ps')) !== '' || trim((string)@shell_exec('command -v pdf2ps')) !== '';
$hasPs2pdf = trim((string)@shell_exec('which ps2pdf')) !== '' || trim((string)@shell_exec('command -v ps2pdf')) !== '';
if (!$hasPdf2ps || !$hasPs2pdf) {
throw new Exception('[cleanUpload] pdf2ps/ps2pdf not available on server.');
}
$inputPDF = $fullPathNameNew;
$outputPDF = $fullPathNameNew . '.tmp';
$tempPS = $fullPathNameNew . '.ps';
$cmd1 = 'pdf2ps ' . escapeshellarg($inputPDF) . ' ' . escapeshellarg($tempPS);
$cmd2 = 'ps2pdf -dPDFSETTINGS=/prepress ' . escapeshellarg($tempPS) . ' ' . escapeshellarg($outputPDF);
exec($cmd1, $output1, $return1);
exec($cmd2, $output2, $return2);
if ($return1 !== 0 || $return2 !== 0 || !file_exists($outputPDF)) {
if (file_exists($tempPS)) { @unlink($tempPS); }
if (file_exists($outputPDF)) { @unlink($outputPDF); }
throw new Exception('[cleanUpload] PDF processing failed.');
}
@rename($outputPDF, $inputPDF);
if (file_exists($tempPS)) { @unlink($tempPS); }
}
break;
}
} catch (Exception $e) {
$modx->log(modX::LOG_LEVEL_ERROR, $e->getMessage());
}
}
unset($file);