AJenbo/agcms

View on GitHub
application/inc/Http/Controllers/Admin/ExplorerController.php

Summary

Maintainability
F
4 days
Test Coverage
F
0%
<?php

namespace App\Http\Controllers\Admin;

use App\Exceptions\Exception;
use App\Exceptions\InvalidInput;
use App\Http\Request;
use App\Models\CustomPage;
use App\Models\File;
use App\Models\InterfaceRichText;
use App\Models\Newsletter;
use App\Models\Page;
use App\Models\Requirement;
use App\Render;
use App\Services\ConfigService;
use App\Services\DbService;
use App\Services\FileService;
use App\Services\ImageService;
use App\Services\OrmService;
use App\Services\RenderService;
use App\Services\UploadHandler;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;

class ExplorerController extends AbstractAdminController
{
    private FileService $fileService;

    public function __construct()
    {
        $this->fileService = new FileService();
    }

    /**
     * Show the file manager.
     */
    public function index(Request $request): Response
    {
        $currentDir = strval($request->cookies->get('admin_dir', '/images'));

        $data = [
            'returnType' => $request->get('return', ''),
            'returnid'   => $request->get('returnid', ''),
            'bgcolor'    => ConfigService::getString('bgcolor'),
            'dirs'       => $this->fileService->getRootDirs($currentDir),
        ];

        return $this->render('admin/explorer', $data);
    }

    /**
     * Render subfolder.
     */
    public function folders(Request $request): JsonResponse
    {
        $path = $request->query->get('path');
        if (!is_string($path)) {
            $path = '';
        }
        $move = $request->query->getBoolean('move');
        $currentDir = strval($request->cookies->get('admin_dir', '/images'));

        $html = app(RenderService::class)->render(
            'admin/partial-listDirs',
            [
                'dirs' => $this->fileService->getSubDirs($path, $currentDir),
                'move' => $move,
            ]
        );

        return new JsonResponse(['id' => $path, 'html' => $html]);
    }

    /**
     * Display a list of files in the selected folder.
     *
     * @todo only output json, let fronend generate html and init objects
     */
    public function files(Request $request): JsonResponse
    {
        $path = $request->query->get('path');
        if (!is_string($path)) {
            $path = '';
        }
        $returnType = $request->query->get('return');
        if (!is_string($returnType)) {
            $returnType = '';
        }

        $this->fileService->checkPermittedPath($path);

        $app = app();

        $files = scandir($app->basePath($path)) ?: [];
        natcasesort($files);

        $html = '';
        $fileData = [];
        foreach ($files as $fileName) {
            if ('.' === mb_substr($fileName, 0, 1) || is_dir($app->basePath($path . '/' . $fileName))) {
                continue;
            }

            $filePath = $path . '/' . $fileName;
            $file = File::getByPath($filePath);
            if (!$file) {
                $file = File::fromPath($filePath)->save();
            }

            $html .= $this->fileService->filehtml($file, $returnType);
            $fileData[] = $this->fileService->fileAsArray($file);
        }

        return new JsonResponse(['id' => 'files', 'html' => $html, 'files' => $fileData]);
    }

    /**
     * Search for files.
     */
    public function search(Request $request): JsonResponse
    {
        $returnType = $request->query->get('return');
        if (!is_string($returnType)) {
            $returnType = '';
        }
        $qpath = $request->query->get('qpath');
        if (!is_string($qpath)) {
            $qpath = '';
        }
        $qalt = $request->query->get('qalt');
        if (!is_string($qalt)) {
            $qalt = '';
        }

        $qtype = $request->query->get('qtype');
        if (!is_string($qtype)) {
            $qtype = '';
        }

        $html = '';
        $fileData = [];
        $query = $this->buildSearchQuery($qpath, $qalt, $qtype);

        $files = app(OrmService::class)->getByQuery(File::class, $query);
        foreach ($files as $file) {
            if ('unused' !== $qtype || !$file->isInUse()) {
                $html .= $this->fileService->filehtml($file, $returnType);
                $fileData[] = $this->fileService->fileAsArray($file);
            }
        }

        return new JsonResponse(['id' => 'files', 'html' => $html, 'files' => $fileData]);
    }

    private function buildSearchQuery(string $qpath, string $qalt, string $qtype): string
    {
        $db = app(DbService::class);
        $qpath = $db->escapeWildcards($qpath);
        $qalt = $db->escapeWildcards($qalt);

        $sqlMime = $this->getMimeClause($qtype);

        //Generate search query
        $sql = ' FROM `files`';
        if ($qpath || $qalt || $sqlMime) {
            $sql .= ' WHERE ';
            if ($qpath || $qalt) {
                $sql .= '(';
            }
            if ($qpath) {
                $sql .= 'MATCH(path) AGAINST(' . $db->quote($qpath) . ')>0';
            }
            if ($qpath && $qalt) {
                $sql .= ' OR ';
            }
            if ($qalt) {
                $sql .= 'MATCH(alt) AGAINST(' . $db->quote($qalt) . ')>0';
            }
            if ($qpath) {
                $sql .= ' OR `path` LIKE ' . $db->quote('%' . $qpath . '%');
            }
            if ($qalt) {
                $sql .= ' OR `alt` LIKE ' . $db->quote('%' . $qalt . '%');
            }
            if ($qpath || $qalt) {
                $sql .= ')';
            }
            if (($qpath || $qalt) && !empty($sqlMime)) {
                $sql .= ' AND ';
            }
            if (!empty($sqlMime)) {
                $sql .= $sqlMime;
            }
        }

        $sqlSelect = '';
        if ($qpath || $qalt) {
            $sqlSelect .= ', ';
            if ($qpath && $qalt) {
                $sqlSelect .= '(';
            }
            if ($qpath) {
                $sqlSelect .= 'MATCH(path) AGAINST(' . $db->quote($qpath) . ')';
            }
            if ($qpath && $qalt) {
                $sqlSelect .= ' + ';
            }
            if ($qalt) {
                $sqlSelect .= 'MATCH(alt) AGAINST(' . $db->quote($qalt) . ')';
            }
            if ($qpath && $qalt) {
                $sqlSelect .= ')';
            }
            $sqlSelect .= ' AS score';
            $sql = $sqlSelect . $sql;
            $sql .= ' ORDER BY `score` DESC';
        }

        return 'SELECT *' . $sql;
    }

    private function getMimeClause(string $qtype): string
    {
        switch ($qtype) {
            case 'image':
                return "mime IN('image/jpeg', 'image/png', 'image/gif')";
            case 'imagefile':
                return "mime LIKE 'image/%' AND mime NOT IN('image/jpeg', 'image/png', 'image/gif')";
            case 'video':
                return "mime LIKE 'video/%'";
            case 'audio':
                return "mime LIKE 'audio/%'";
            case 'text':
                return "(
                    mime IN(
                        'application/pdf',
                        'application/msword',
                        'application/vnd.ms-%',
                        'application/vnd.openxmlformats-officedocument.%',
                        'application/vnd.oasis.opendocument.%'
                )";
            case 'compressed':
                return "mime = 'application/zip'";
            default:
                return '';
        }
    }

    public function fileDelete(Request $request, int $id): JsonResponse
    {
        $file = app(OrmService::class)->getOne(File::class, $id);
        if ($file) {
            if ($file->isInUse()) {
                throw new InvalidInput(_('The file can not be deleted because it is in use.'), Response::HTTP_LOCKED);
            }

            $file->delete();
        }

        return new JsonResponse(['id' => $id]);
    }

    /**
     * Create new folder.
     */
    public function folderCreate(Request $request): JsonResponse
    {
        $path = $request->query->get('path');
        if (!is_string($path)) {
            $path = '';
        }
        $name = $request->query->get('name');
        if (!is_string($name)) {
            $name = '';
        }
        $name = $this->fileService->cleanFileName($name);
        $newPath = $path . '/' . $name;

        $this->fileService->createFolder($newPath);

        return new JsonResponse([]);
    }

    /**
     * Endpoint for deleting a folder.
     */
    public function folderDelete(Request $request): JsonResponse
    {
        $path = $request->query->get('path');
        if (!is_string($path)) {
            $path = '';
        }
        $this->fileService->deleteFolder($path);

        return new JsonResponse([]);
    }

    public function fileView(Request $request, int $id): Response
    {
        $file = app(OrmService::class)->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $template = 'admin/popup-image';
        if (0 === mb_strpos($file->getMime(), 'video/')) {
            $template = 'admin/popup-video';
        } elseif (0 === mb_strpos($file->getMime(), 'audio/')) {
            $template = 'admin/popup-audio';
        }

        return $this->render($template, ['file' => $file]);
    }

    /**
     * Check if a file already exists.
     */
    public function fileExists(Request $request): JsonResponse
    {
        $path = $request->query->get('path');
        if (!is_string($path)) {
            $path = '';
        }
        $type = $request->query->get('type');
        if (!is_string($type)) {
            $type = '';
        }

        $pathinfo = pathinfo($path);

        if ('image' === $type) {
            $pathinfo['extension'] = 'jpg';
        } elseif ('lineimage' === $type) {
            $pathinfo['extension'] = 'png';
        } elseif (empty($pathinfo['extension'])) {
            $pathinfo['extension'] = 'jpg';
        }

        $path = ($pathinfo['dirname'] ?? '') . '/' . $this->fileService->cleanFileName($pathinfo['filename']);
        $fullPath = app()->basePath($path);
        $fullPath .= '.' . $pathinfo['extension'];

        return new JsonResponse(['exists' => is_file($fullPath), 'name' => basename($fullPath)]);
    }

    /**
     * Update image description.
     *
     * @todo make db fixer check for missing alt="" in <img>
     */
    public function fileDescription(Request $request, int $id): JsonResponse
    {
        $orm = app(OrmService::class);

        $file = $orm->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $description = $request->getRequestString('description') ?? '';
        $file->setDescription($description)->save();

        $db = app(DbService::class);

        foreach ([Page::class, CustomPage::class, Requirement::class, Newsletter::class] as $className) {
            $richTexts = $orm->getByQuery(
                $className,
                'SELECT * FROM `' . $className::TABLE_NAME
                    . '` WHERE `text` LIKE ' . $db->quote('%="' . $file->getPath() . '"%')
            );
            $this->updateAltInHtml($richTexts, $file);
        }

        return new JsonResponse(['id' => $id, 'description' => $description]);
    }

    /**
     * Update alt text for images in HTML text.
     *
     * @param InterfaceRichText[] $richTexts
     *
     * @throws Exception
     */
    private function updateAltInHtml(array $richTexts, File $file): void
    {
        foreach ($richTexts as $richText) {
            $html = $richText->getHtml();
            $html = preg_replace(
                [
                    '/(<img[^>]+src="' . preg_quote($file->getPath(), '/') . '"[^>]+alt=")[^"]*("[^>]*>)/iu',
                    '/(<img[^>]+alt=")[^"]*("[^>]+src="' . preg_quote($file->getPath(), '/') . '"[^>]*>)/iu',
                ],
                '\1' . htmlspecialchars($file->getDescription(), ENT_COMPAT | ENT_XHTML) . '\2',
                $html
            );
            if (null === $html) {
                throw new Exception('preg_replace failed');
            }
            $richText->setHtml($html)->save();
        }
    }

    /**
     * File viwer.
     */
    public function fileMoveDialog(Request $request, int $id): Response
    {
        $currentDir = strval($request->cookies->get('admin_dir', '/images'));

        $file = app(OrmService::class)->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $data = [
            'file' => $file,
            'dirs' => $this->fileService->getRootDirs($currentDir),
        ];

        return $this->render('admin/file-move', $data);
    }

    /**
     * Upload dialog.
     */
    public function fileUploadDialog(Request $request): Response
    {
        $maxbyte = min(
            $this->fileService->returnBytes(ini_get('post_max_size') ?: '0'),
            $this->fileService->returnBytes(ini_get('upload_max_filesize') ?: '0')
        );

        $data = [
            'maxbyte'   => $maxbyte,
            'activeDir' => $request->get('path'),
        ];

        return $this->render('admin/file-upload', $data);
    }

    public function fileUpload(Request $request): Response
    {
        /** @var ?UploadedFile */
        $uploadedFile = $request->files->get('upload');
        if (!$uploadedFile) {
            throw new InvalidInput(_('No file received.'));
        }

        $currentDir = strval($request->cookies->get('admin_dir', '/images'));
        $targetDir = $request->getRequestString('dir') ?? $currentDir;
        $destinationType = $request->getRequestString('type') ?? '';
        $description = $request->getRequestString('alt') ?? '';

        $uploadHandler = new UploadHandler($targetDir);
        $file = $uploadHandler->process($uploadedFile, $destinationType, $description);

        $data = [
            'uploaded' => 1,
            'fileName' => basename($file->getPath()),
            'url'      => $file->getPath(),
            'width'    => $file->getWidth(),
            'height'   => $file->getHeight(),
        ];

        return new JsonResponse($data);
    }

    /**
     * Rename or relocate file.
     *
     * @throws Exception
     */
    public function fileRename(Request $request, int $id): JsonResponse
    {
        try {
            $file = app(OrmService::class)->getOne(File::class, $id);
            if (!$file) {
                throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
            }

            $pathinfo = pathinfo($file->getPath());

            $dir = $request->getRequestString('dir') ?? ($pathinfo['dirname'] ?? '');
            $filename = $request->getRequestString('name') ?? $pathinfo['filename'];
            $filename = $this->fileService->cleanFileName($filename);
            $overwrite = $request->request->getBoolean('overwrite');

            $ext = $pathinfo['extension'] ?? '';
            $ext = $ext ? '.' . $ext : '';
            $newPath = $dir . '/' . $filename . $ext;

            if ($file->getPath() === $newPath) {
                return new JsonResponse(['id' => $id, 'filename' => $filename, 'path' => $file->getPath()]);
            }

            if (!$filename) {
                throw new InvalidInput(_('The name is invalid.'));
            }

            $this->fileService->checkPermittedTargetPath($newPath);

            $existingFile = File::getByPath($newPath);
            if ($existingFile) {
                if ($existingFile->isInUse()) {
                    throw new InvalidInput(_('File already exists.'));
                }

                if (!$overwrite) {
                    return new JsonResponse([
                        'yesno' => _(
                            'A file with the same name already exists. Would you like to replace the existing file?'
                        ),
                        'id'    => $id,
                    ]);
                }

                $existingFile->delete();
            }

            if (!$file->move($newPath)) {
                throw new Exception(_('An error occurred with the file operations.'));
            }
        } catch (InvalidInput $exception) {
            return new JsonResponse(
                ['error' => ['message' => $exception->getMessage()], 'id' => $id],
                Response::HTTP_BAD_REQUEST
            );
        }

        return new JsonResponse(['id' => $id, 'filename' => $filename, 'path' => $newPath]);
    }

    /**
     * Rename directory.
     *
     * @throws Exception
     */
    public function folderRename(Request $request): JsonResponse
    {
        $path = $request->getRequestString('path') ?? '';
        $name = $request->getRequestString('name') ?? '';
        $name = $this->fileService->cleanFileName($name);
        $overwrite = $request->request->getBoolean('overwrite');

        $dirname = pathinfo($path, PATHINFO_DIRNAME);
        $newPath = $dirname . '/' . $name;

        if ($path === $newPath) {
            return new JsonResponse(['filename' => $name, 'path' => $path, 'newPath' => $newPath]);
        }

        try {
            if (!$name) {
                throw new InvalidInput(_('The name is invalid.'));
            }

            $this->fileService->checkPermittedTargetPath($path);

            $app = app();

            if (file_exists($app->basePath($newPath))) {
                if (!$overwrite) {
                    return new JsonResponse([
                        'yesno' => _(
                            'A file with the same name already exists. Would you like to replace the existing file?'
                        ),
                        'path'  => $path,
                    ]);
                }

                $this->fileService->deleteFolder($newPath);
            }

            if (!rename($app->basePath($path), $app->basePath($newPath))) {
                throw new Exception(_('An error occurred with the file operations.'));
            }
        } catch (InvalidInput $exception) {
            return new JsonResponse(
                ['error' => ['message' => $exception->getMessage()], 'path' => $path],
                Response::HTTP_BAD_REQUEST
            );
        }

        $this->fileService->replaceFolderPaths($path, $newPath);

        return new JsonResponse(['filename' => $name, 'path' => $path, 'newPath' => $newPath]);
    }

    /**
     * Image editing window.
     */
    public function imageEditWidget(Request $request, int $id): Response
    {
        $file = app(OrmService::class)->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $mode = $request->get('mode');

        $fileName = '';
        if ('thb' === $mode) {
            $fileName = pathinfo($file->getPath(), PATHINFO_FILENAME) . '-thb';
        }

        $data = [
            'textWidth'   => ConfigService::getInt('text_width'),
            'thumbWidth'  => ConfigService::getInt('thumb_width'),
            'thumbHeight' => ConfigService::getInt('thumb_height'),
            'mode'        => $mode,
            'fileName'    => $fileName,
            'file'        => $file,
        ];

        return $this->render('admin/image-edit', $data);
    }

    /**
     * Dynamic image.
     *
     * @throws Exception
     */
    public function image(Request $request, int $id): Response
    {
        $file = app(OrmService::class)->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $path = $file->getPath();

        $noCache = $request->query->getBoolean('noCache');

        $app = app();

        $timestamp = filemtime($app->basePath($path));
        if (false === $timestamp) {
            throw new Exception('File not found.', Response::HTTP_NOT_FOUND);
        }

        if (!$noCache) {
            $response = $this->cachedResponse(null, $timestamp, 2592000);
            if ($response->isNotModified($request)) {
                return $response;
            }
        }

        $image = $this->createImageServiceFomRequest($request->query, $app->basePath($path));
        if ($image->isNoOp()) {
            return redirect($path, Response::HTTP_MOVED_PERMANENTLY);
        }

        $targetPath = tempnam(sys_get_temp_dir(), 'image');
        if (!$targetPath) {
            throw new Exception('Failed to create temporary file');
        }

        $type = 'jpeg';
        if ('image/jpeg' !== $file->getMime()) {
            $type = 'png';
        }

        $image->processImage($targetPath, $type);

        $response = new BinaryFileResponse($targetPath);
        $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, pathinfo($path, PATHINFO_BASENAME));

        return $this->cachedResponse($response, $timestamp, 2592000);
    }

    /**
     * Process an image.
     */
    public function imageSave(Request $request, int $id): Response
    {
        $file = app(OrmService::class)->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $path = $file->getPath();
        $fullPath = app()->basePath($path);

        $image = $this->createImageServiceFomRequest($request->request, $fullPath);
        if ($image->isNoOp()) {
            return $this->createImageResponse($file);
        }
        if ($file->isInUse(true)) {
            throw new InvalidInput(_('Image can not be changed as it used in a text.'), Response::HTTP_LOCKED);
        }

        $type = 'jpeg';
        $mime = 'image/jpeg';
        if ('image/jpeg' !== $file->getMime()) {
            $type = 'png';
            $mime = 'image/png';
        }

        $image->processImage($fullPath, $type);

        $file->setWidth($image->getWidth())
            ->setHeight($image->getHeight())
            ->setMime($mime)
            ->setSize(filesize($fullPath) ?: 0)
            ->save();

        return $this->createImageResponse($file);
    }

    /**
     * Generate a thumbnail image from an existing image.
     */
    public function imageSaveThumb(Request $request, int $id): Response
    {
        $file = app(OrmService::class)->getOne(File::class, $id);
        if (!$file) {
            throw new InvalidInput(_('File not found.'), Response::HTTP_NOT_FOUND);
        }

        $path = $file->getPath();

        $app = app();

        $image = $this->createImageServiceFomRequest($request->request, $app->basePath($path));
        if ($image->isNoOp()) {
            return $this->createImageResponse($file);
        }

        $type = 'jpeg';
        $ext = 'jpg';
        $mime = 'image/jpeg';
        if ('image/jpeg' !== $file->getMime()) {
            $type = 'png';
            $ext = 'png';
            $mime = 'image/png';
        }

        $pathInfo = pathinfo($path);
        $newPath = ($pathInfo['dirname'] ?? '') . '/' . $pathInfo['filename'] . '-thb.' . $ext;

        if (File::getByPath($newPath)) {
            throw new InvalidInput(_('Thumbnail already exists.'));
        }
        $image->processImage($app->basePath($newPath), $type);

        $newFile = File::fromPath($newPath);
        $newFile->setDescription($file->getDescription())->save();

        return $this->createImageResponse($newFile);
    }

    /**
     * Create an image service from a path and the request parameteres.
     *
     * @param ParameterBag<string, int> $parameterBag
     */
    private function createImageServiceFomRequest(ParameterBag $parameterBag, string $path): ImageService
    {
        $image = new ImageService($path);
        $image->setCrop(
            $parameterBag->getInt('cropX'),
            $parameterBag->getInt('cropY'),
            $parameterBag->getInt('cropW'),
            $parameterBag->getInt('cropH')
        );
        $image->setScale($parameterBag->getInt('maxW'), $parameterBag->getInt('maxH'));
        $image->setFlip($parameterBag->getInt('flip'));
        $image->setRotate($parameterBag->getInt('rotate'));

        return $image;
    }

    /**
     * Create an image response for the image editor.
     */
    private function createImageResponse(File $file): JsonResponse
    {
        return new JsonResponse([
            'id'     => $file->getId(),
            'path'   => $file->getPath(),
            'width'  => $file->getWidth(),
            'height' => $file->getHeight(),
        ]);
    }
}