diff --git a/config/services.yaml b/config/services.yaml index 252f8f36..4c366ec8 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -74,6 +74,13 @@ services: arguments: $timezone: '%timezone%' + App\Services\Attachments\AttachmentPathResolver: + arguments: + $project_dir: '%kernel.project_dir%' + $media_path: '%media_directory%' + $footprints_path: 'public/img/footprints' + $models_path: null + App\Services\TranslationExtractor\PermissionExtractor: tags: - { name: 'translation.extractor', alias: 'permissionExtractor'} \ No newline at end of file diff --git a/src/Command/CleanAttachmentsCommand.php b/src/Command/CleanAttachmentsCommand.php index 5cf49443..b32a98c9 100644 --- a/src/Command/CleanAttachmentsCommand.php +++ b/src/Command/CleanAttachmentsCommand.php @@ -4,6 +4,7 @@ namespace App\Command; use App\Services\AttachmentHelper; use App\Services\AttachmentReverseSearch; +use App\Services\Attachments\AttachmentPathResolver; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputArgument; @@ -24,10 +25,12 @@ class CleanAttachmentsCommand extends Command protected $attachment_helper; protected $reverseSearch; protected $mimeTypeGuesser; + protected $pathResolver; - public function __construct(AttachmentHelper $attachmentHelper, AttachmentReverseSearch $reverseSearch) + public function __construct(AttachmentHelper $attachmentHelper, AttachmentReverseSearch $reverseSearch, AttachmentPathResolver $pathResolver) { $this->attachment_helper = $attachmentHelper; + $this->pathResolver = $pathResolver; $this->reverseSearch = $reverseSearch; $this->mimeTypeGuesser = new MimeTypes(); parent::__construct(); @@ -45,7 +48,7 @@ class CleanAttachmentsCommand extends Command { $io = new SymfonyStyle($input, $output); - $mediaPath = $this->attachment_helper->getMediaPath(); + $mediaPath = $this->pathResolver->getMediaPath(); $io->note("The media path is " . $mediaPath); $finder = new Finder(); diff --git a/src/EntityListeners/AttachmentDeleteListener.php b/src/EntityListeners/AttachmentDeleteListener.php index b2572063..222ab888 100644 --- a/src/EntityListeners/AttachmentDeleteListener.php +++ b/src/EntityListeners/AttachmentDeleteListener.php @@ -35,6 +35,7 @@ namespace App\EntityListeners; use App\Entity\Attachments\Attachment; use App\Services\AttachmentHelper; use App\Services\AttachmentReverseSearch; +use App\Services\Attachments\AttachmentPathResolver; use Doctrine\ORM\Event\LifecycleEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Mapping\PostRemove; @@ -49,11 +50,13 @@ class AttachmentDeleteListener { protected $attachmentReverseSearch; protected $attachmentHelper; + protected $pathResolver; - public function __construct(AttachmentReverseSearch $attachmentReverseSearch, AttachmentHelper $attachmentHelper) + public function __construct(AttachmentReverseSearch $attachmentReverseSearch, AttachmentHelper $attachmentHelper, AttachmentPathResolver $pathResolver) { $this->attachmentReverseSearch = $attachmentReverseSearch; $this->attachmentHelper = $attachmentHelper; + $this->pathResolver = $pathResolver; } /** @@ -71,7 +74,7 @@ class AttachmentDeleteListener return; } - $file = new \SplFileInfo($this->attachmentHelper->placeholderToRealPath($event->getOldValue('path'))); + $file = new \SplFileInfo($this->pathResolver->placeholderToRealPath($event->getOldValue('path'))); $this->attachmentReverseSearch->deleteIfNotUsed($file); } } diff --git a/src/Services/AttachmentHelper.php b/src/Services/AttachmentHelper.php index 7e74fe6f..f22c9245 100644 --- a/src/Services/AttachmentHelper.php +++ b/src/Services/AttachmentHelper.php @@ -45,50 +45,17 @@ use App\Entity\Attachments\PartAttachment; use App\Entity\Attachments\StorelocationAttachment; use App\Entity\Attachments\SupplierAttachment; use App\Entity\Attachments\UserAttachment; -use App\Entity\Parts\Part; -use App\Entity\UserSystem\Group; -use App\Entity\UserSystem\User; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Component\Filesystem\Filesystem; +use App\Services\Attachments\AttachmentPathResolver; use Symfony\Component\HttpFoundation\File\UploadedFile; -use Symfony\Component\HttpKernel\KernelInterface; class AttachmentHelper { - /** - * @var string The folder where the attachments are saved. By default this is data/media in the project root string - */ - protected $base_path; - protected $footprints_path; + protected $pathResolver; - protected $footprints_3d_path; - - public function __construct(ParameterBagInterface $params, KernelInterface $kernel) + public function __construct(AttachmentPathResolver $pathResolver) { - $tmp_base_path = $params->get('media_directory'); - - $fs = new Filesystem(); - - //Determine if it is an absolute path, or if we need to create a real absolute one out of it - if ($fs->isAbsolutePath($tmp_base_path)) { - $this->base_path = $tmp_base_path; - } else { - $this->base_path = realpath($kernel->getProjectDir() . DIRECTORY_SEPARATOR . $tmp_base_path); - } - - $this->footprints_path = realpath($kernel->getProjectDir() . "/public/img/footprints"); - //TODO - $this->footprints_3d_path = "TODO"; - } - - /** - * Returns the absolute path to the folder where all attachments are saved. - * @return string - */ - public function getMediaPath() : string - { - return $this->base_path; + $this->pathResolver = $pathResolver; } /** @@ -106,46 +73,6 @@ class AttachmentHelper return new \SplFileInfo($this->toAbsoluteFilePath($attachment)); } - /** - * Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk. - * @param string $placeholder_path The filepath with placeholder for which the real path should be determined. - * @return string The absolute real path of the file - */ - public function placeholderToRealPath(string $placeholder_path) : string - { - $placeholders = ["%MEDIA%", "%BASE%/data/media", "%FOOTPRINTS%", "%FOOTPRINTS_3D"]; - $targets = [$this->base_path, $this->base_path, $this->footprints_path, $this->footprints_3d_path]; - - //The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory - //Older path entries are given via %BASE% which was the project root - $placeholder_path = str_replace($placeholders, $targets, $placeholder_path); - - //Normalize path and remove .. - $placeholder_path = str_replace(['\\','..'], ['/',''], $placeholder_path); - - return $placeholder_path; - } - - /** - * Converts an real absolute filepath to a placeholder version. - * @param string $real_path The absolute path, for which the placeholder version should be generated. - * @param bool $old_version By default the %MEDIA% placeholder is used, which is directly replaced with the - * media directory. If set to true, the old version with %BASE% will be used, which is the project directory. - * @return string The placeholder version of the filepath - */ - public function realPathToPlaceholder(string $real_path, bool $old_version = false) : string - { - if ($old_version) { - $real_path = str_replace($this->base_path, "%BASE%/data/media", $real_path); - } else { - $real_path = str_replace($this->base_path, "%MEDIA%", $real_path); - } - - //Normalize path - $real_path = str_replace('\\', '/', $real_path); - return $real_path; - } - /** * Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved. * @param Attachment $attachment The attachment for which the filepath should be determined @@ -162,7 +89,7 @@ class AttachmentHelper } $path = $attachment->getPath(); - $path = $this->placeholderToRealPath($path); + $path = $this->pathResolver->placeholderToRealPath($path); return realpath($path); } @@ -264,7 +191,7 @@ class AttachmentHelper $file_path = $file->move($folder, $newFilename)->getRealPath(); //Make our file path relative to %BASE% - $file_path = $this->realPathToPlaceholder($file_path); + $file_path = $this->pathResolver->realPathToPlaceholder($file_path); //Save the path to the attachment $attachment->setPath($file_path); diff --git a/src/Services/AttachmentReverseSearch.php b/src/Services/AttachmentReverseSearch.php index d80ac220..7cd26f60 100644 --- a/src/Services/AttachmentReverseSearch.php +++ b/src/Services/AttachmentReverseSearch.php @@ -32,6 +32,7 @@ namespace App\Services; use App\Entity\Attachments\Attachment; +use App\Services\Attachments\AttachmentPathResolver; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\File\File; @@ -43,12 +44,12 @@ use Symfony\Component\HttpFoundation\File\File; class AttachmentReverseSearch { protected $em; - protected $attachment_helper; + protected $pathResolver; - public function __construct(EntityManagerInterface $em, AttachmentHelper $attachmentHelper) + public function __construct(EntityManagerInterface $em, AttachmentPathResolver $pathResolver) { $this->em = $em; - $this->attachment_helper = $attachmentHelper; + $this->pathResolver = $pathResolver; } /** @@ -59,9 +60,9 @@ class AttachmentReverseSearch public function findAttachmentsByFile(\SplFileInfo $file) : array { //Path with %MEDIA% - $relative_path_new = $this->attachment_helper->realPathToPlaceholder($file->getPathname()); + $relative_path_new = $this->pathResolver->realPathToPlaceholder($file->getPathname()); //Path with %BASE% - $relative_path_old = $this->attachment_helper->realPathToPlaceholder($file->getPathname(), true); + $relative_path_old = $this->pathResolver->realPathToPlaceholder($file->getPathname(), true); $repo = $this->em->getRepository(Attachment::class); return $repo->findBy(['path' => [$relative_path_new, $relative_path_old]]); diff --git a/src/Services/Attachments/AttachmentPathResolver.php b/src/Services/Attachments/AttachmentPathResolver.php new file mode 100644 index 00000000..6f150253 --- /dev/null +++ b/src/Services/Attachments/AttachmentPathResolver.php @@ -0,0 +1,245 @@ +project_dir = $project_dir; + + //Determine the path for our ressources + $this->media_path = $this->parameterToAbsolutePath($media_path); + /* if ($this->media_path === null) { + throw new \RuntimeException("MediaPath is not existing/valid! This parameter is not allowed!"); + } */ + $this->footprints_path = $this->parameterToAbsolutePath($footprints_path); + $this->models_path = $this->parameterToAbsolutePath($models_path); + + //Here we define the valid placeholders and their replacement values + $this->placeholders = ['%MEDIA%', '%BASE%/data/media', '%FOOTPRINTS%', '%FOOTPRINTS_3D%']; + $this->pathes = [$this->media_path, $this->media_path, $this->footprints_path, $this->models_path]; + + //Remove all disabled placeholders + foreach ($this->pathes as $key => $path) { + if ($path === null) { + unset($this->placeholders[$key], $this->pathes[$key]); + } + } + + //Create the regex arrays + $this->placeholders_regex = $this->arrayToRegexArray($this->placeholders); + $this->pathes_regex = $this->arrayToRegexArray($this->pathes); + } + + /** + * Converts a path passed by parameter from services.yaml (which can be an absolute path or relative to project dir) + * to an absolute path. When a relative path is passed, the directory must exist or null is returned. + * @internal + * @param string|null $param_path The parameter value that should be converted to a absolute path + * @return string|null + */ + public function parameterToAbsolutePath(?string $param_path) : ?string + { + if ($param_path === null) { + return null; + } + + $fs = new Filesystem(); + //If current string is already an absolute path, then we have nothing to do + if ($fs->isAbsolutePath($param_path)) { + $tmp = realpath($param_path); + //Disable ressource if path is not existing + if ($tmp === false) { + return null; + } + return $tmp; + } + + //Otherwise prepend the project path + $tmp = realpath($this->project_dir . DIRECTORY_SEPARATOR . $param_path); + + //If path does not exist then disable the placeholder + if ($tmp === false) { + return null; + } + + //Otherwise return resolved path + return $tmp; + } + + /** + * Create an array usable for preg_replace out of an array of placeholders or pathes. + * Slashes and other chars become escaped. + * For example: '%TEST%' becomes '/^%TEST%/'. + * @param array $array + * @return array + */ + protected function arrayToRegexArray(array $array) : array + { + $ret = []; + + foreach ($array as $item) { + $item = str_replace(['\\'], ['/'], $item); + $ret[] = '/' . preg_quote($item, '/') . '/'; + } + + return $ret; + } + + + /** + * Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk. + * The directory separator is always /. Relative pathes are not realy possible (.. is striped) + * @param string $placeholder_path The filepath with placeholder for which the real path should be determined. + * @return string|null The absolute real path of the file, or null if the placeholder path is invalid + */ + public function placeholderToRealPath(string $placeholder_path) : ?string + { + //The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory + //Older path entries are given via %BASE% which was the project root + + $count = 0; + $placeholder_path = preg_replace($this->placeholders_regex, $this->pathes, $placeholder_path,-1,$count); + + //A valid placeholder can have only one + if ($count !== 1) { + return null; + } + + //If we have now have a placeholder left, the string is invalid: + if (preg_match('/%\w+%/', $placeholder_path)) { + return null; + } + + //Path is invalid if path is directory traversal + if (strpos($placeholder_path, '..') !== false) { + return null; + } + + //Normalize path and remove .. (to prevent directory traversal attack) + $placeholder_path = str_replace(['\\'], ['/'], $placeholder_path); + + return $placeholder_path; + } + + /** + * Converts an real absolute filepath to a placeholder version. + * @param string $real_path The absolute path, for which the placeholder version should be generated. + * @param bool $old_version By default the %MEDIA% placeholder is used, which is directly replaced with the + * media directory. If set to true, the old version with %BASE% will be used, which is the project directory. + * @return string The placeholder version of the filepath + */ + public function realPathToPlaceholder(string $real_path, bool $old_version = false) : ?string + { + $count = 0; + + //Normalize path + $real_path = str_replace('\\', '/', $real_path); + + if ($old_version) { + //We need to remove the %MEDIA% placeholder (element 0) + $pathes = $this->pathes_regex; + $placeholders = $this->placeholders; + unset($pathes[0], $placeholders[0]); + $real_path = preg_replace($pathes, $placeholders, $real_path, -1, $count); + } else { + $real_path = preg_replace($this->pathes_regex, $this->placeholders, $real_path, -1, $count); + } + + if ($count !== 1) { + return null; + } + + //If the new string does not begin with a placeholder, it is invalid + if (!preg_match('/^%\w+%/', $real_path)) { + return null; + } + + return $real_path; + } + + /** + * The path where uploaded attachments is stored. + * @return string The absolute path to the media folder. + */ + public function getMediaPath() : string + { + return $this->media_path; + } + + /** + * The string where the builtin footprints are stored + * @return string|null The absolute path to the footprints folder. Null if built footprints were disabled. + */ + public function getFootprintsPath() : ?string + { + return $this->footprints_path; + } + + /** + * The string where the builtin 3D models are stored + * @return string|null The absolute path to the models folder. Null if builtin models were disabled. + */ + public function getModelsPath() : ?string + { + return $this->models_path; + } +} \ No newline at end of file diff --git a/src/Services/BuiltinAttachmentsFinder.php b/src/Services/BuiltinAttachmentsFinder.php new file mode 100644 index 00000000..53e2b288 --- /dev/null +++ b/src/Services/BuiltinAttachmentsFinder.php @@ -0,0 +1,96 @@ +pathResolver = $pathResolver; + } + + protected function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'limit' => 15, //Given only 15 entries + 'filename_filter' => '', //Filter the filenames. For example *.jpg to only get jpegs. Can also be an array + 'placeholders' => Attachment::BUILTIN_PLACEHOLDER, //By default use all builtin ressources + ]); + } + + public function find(string $keyword, array $options) : array + { + $finder = new Finder(); + + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); + + //We search only files + $finder->files(); + $finder->in($this->pathResolver->getFootprintsPath()); + + //Apply filter if needed + if (!empty($options['filename_filter'])) { + $finder->name($options['filename_filter']); + } + + $finder->path($keyword); + + $arr = []; + + $limit = $options['limit']; + + foreach ($finder as $file) { + if ($limit <= 0) { + break; + } + $arr[] = $this->pathResolver->realPathToPlaceholder($file->getPathname()); + $limit--; + } + + return $arr; + } + +} \ No newline at end of file diff --git a/tests/Services/Attachments/AttachmentPathResolverTest.php b/tests/Services/Attachments/AttachmentPathResolverTest.php new file mode 100644 index 00000000..cd0c03d6 --- /dev/null +++ b/tests/Services/Attachments/AttachmentPathResolverTest.php @@ -0,0 +1,143 @@ +getProjectDir()); + self::$projectDir = str_replace('\\', '/', self::$projectDir_orig); + self::$media_path = self::$projectDir . '/data/media'; + self::$footprint_path = self::$projectDir . '/public/img/footprints'; + } + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + + //Get an service instance. + self::bootKernel(); + self::$service = self::$container->get(AttachmentPathResolver::class); + } + + public function testParameterToAbsolutePath() + { + //If null is passed, null must be returned + $this->assertNull(self::$service->parameterToAbsolutePath(null)); + + //Absolute path should be returned like they are (we use projectDir here, because we know that this dir exists) + $this->assertEquals(self::$projectDir_orig, self::$service->parameterToAbsolutePath(self::$projectDir)); + + //Relative pathes should be resolved + $this->assertEquals(self::$projectDir_orig . DIRECTORY_SEPARATOR . 'src', self::$service->parameterToAbsolutePath('src')); + $this->assertEquals(self::$projectDir_orig . DIRECTORY_SEPARATOR . 'src', self::$service->parameterToAbsolutePath('./src')); + + //Invalid pathes should return null + $this->assertNull(self::$service->parameterToAbsolutePath('/this/path/does/not/exist')); + $this->assertNull(self::$service->parameterToAbsolutePath('/./this/one/too')); + } + + public function placeholderDataProvider() + { + return [ + ['%FOOTPRINTS%/test/test.jpg', self::$footprint_path . '/test/test.jpg'], + ['%FOOTPRINTS%/test/', self::$footprint_path . '/test/'], + ['%MEDIA%/test', self::$media_path . '/test'], + ['%MEDIA%', self::$media_path], + ['%FOOTPRINTS%', self::$footprint_path], + //Footprints 3D are disabled + ['%FOOTPRINTS_3D%', null], + //Check that invalid pathes return null + ['/no/placeholder', null], + ['%INVALID_PLACEHOLDER%', null], + ['%FOOTPRINTS/test/', null], //Malformed placeholder + ['/wrong/%FOOTRPINTS%/', null], //Placeholder not at beginning + ['%FOOTPRINTS%/%MEDIA%', null], //No more than one placholder + ['%FOOTPRINTS%/%FOOTPRINTS%', null], + ['%FOOTPRINTS%/../../etc/passwd', null], + ['%FOOTPRINTS%/0\..\test', null] + ]; + } + + public function realPathDataProvider() + { + return [ + [self::$media_path . '/test/img.jpg', '%MEDIA%/test/img.jpg'], + [self::$media_path . '/test/img.jpg', '%BASE%/data/media/test/img.jpg', true], + [self::$footprint_path . '/foo.jpg', '%FOOTPRINTS%/foo.jpg'], + [self::$footprint_path . '/foo.jpg', '%FOOTPRINTS%/foo.jpg', true], + //Every kind of absolute path, that is not based with our placeholder dirs must be invald + ['/etc/passwd', null], + ['C:\\not\\existing.txt', null], + //More then one placeholder is not allowed + [self::$footprint_path . '/test/' . self::$footprint_path, null], + //Path must begin with path + ['/not/root' . self::$footprint_path, null] + ]; + } + + /** + * @dataProvider placeholderDataProvider + */ + public function testPlaceholderToRealPath($param, $expected) + { + $this->assertEquals($expected, self::$service->placeholderToRealPath($param)); + } + + /** + * @dataProvider realPathDataProvider + */ + public function testRealPathToPlaceholder($param, $expected, $old_method = false) + { + $this->assertEquals($expected, self::$service->realPathToPlaceholder($param, $old_method)); + } +} \ No newline at end of file