Skip to main content

🐘 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);