diff --git a/.github/workflows/unused.yml b/.github/workflows/unused.yml index bf07cc7..48788dc 100644 --- a/.github/workflows/unused.yml +++ b/.github/workflows/unused.yml @@ -67,4 +67,4 @@ jobs: fi - name: Detect unused packages - run: composer-unused -vvv --profile --ansi --no-interaction --no-progress --excludePackage=php --excludePackage=tatter/alerts --excludePackage=tatter/preferences --excludePackage=tatter/thumbnails + run: composer-unused -vvv --output-format=github --ansi --no-interaction --no-progress diff --git a/composer-unused.php b/composer-unused.php new file mode 100644 index 0000000..7abb820 --- /dev/null +++ b/composer-unused.php @@ -0,0 +1,14 @@ +addNamedFilter(NamedFilter::fromString('enyo/dropzone')) + ->setAdditionalFilesFor('tatter/preferences', [ + __DIR__ . '/vendor/tatter/preferences/src/Helpers/preferences_helper.php', + ]); +}; diff --git a/composer.json b/composer.json index 2a9fdef..c91c918 100644 --- a/composer.json +++ b/composer.json @@ -23,12 +23,11 @@ "php": "^7.4 || ^8.0", "codeigniter4/authentication-implementation": "^1.0", "enyo/dropzone": "^6.0", - "tatter/alerts": "^2.0", - "tatter/exports": "^2.0", + "tatter/exports": "^3.0", "tatter/frontend": "^1.0", "tatter/permits": "^3.0", "tatter/preferences": "^1.0", - "tatter/thumbnails": "^1.2" + "tatter/thumbnails": "^2.0" }, "require-dev": { "codeigniter4/devkit": "^1.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e718267..7259162 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,7 +22,6 @@ parameters: - src/Helpers - vendor/codeigniter4/framework/system/Helpers - vendor/tatter/alerts/src/Helpers - - vendor/tatter/handlers/src/Helpers - vendor/tatter/imposter/src/Helpers - vendor/tatter/preferences/src/Helpers dynamicConstantNames: diff --git a/src/Config/Files.php b/src/Config/Files.php index 9955480..dbfe735 100644 --- a/src/Config/Files.php +++ b/src/Config/Files.php @@ -77,12 +77,12 @@ public function getPath(): string $this->path = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; // Check or create the thumbnails subdirectory - $thumbnails = $storage . 'thumbnails'; + $thumbnails = $this->path . 'thumbnails'; if (! is_dir($thumbnails) && ! @mkdir($thumbnails, 0775, true)) { throw FilesException::forDirFail($thumbnails); // @codeCoverageIgnore } - return $storage; + return $this->path; } /** diff --git a/src/Controllers/Files.php b/src/Controllers/Files.php index 4d7922a..60efdb1 100644 --- a/src/Controllers/Files.php +++ b/src/Controllers/Files.php @@ -7,7 +7,9 @@ use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\ResponseInterface; +use RuntimeException; use Tatter\Exports\Exceptions\ExportsException; +use Tatter\Exports\Factories\ExporterFactory; use Tatter\Files\Config\Files as FilesConfig; use Tatter\Files\Entities\File; use Tatter\Files\Exceptions\FilesException; @@ -30,7 +32,7 @@ class Files extends Controller /** * Helpers to load. */ - protected $helpers = ['alerts', 'files', 'handlers', 'html', 'preferences', 'text']; + protected $helpers = ['alerts', 'files', 'html', 'preferences', 'text']; /** * Overriding data for views. @@ -139,7 +141,7 @@ public function user($userId = null) if ($userId === null) { // Check for list permission if (! $this->model->mayList()) { - return $this->failure(403, lang('Permits.notPermitted')); + return $this->failure(403, lang('Files.notPermitted')); } $this->setData([ @@ -152,7 +154,7 @@ public function user($userId = null) elseif ((int) $userId !== user_id()) { // Check for list permission if (! $this->model->mayList()) { - return $this->failure(403, lang('Permits.notPermitted')); + return $this->failure(403, lang('Files.notPermitted')); } $this->setData([ @@ -188,7 +190,7 @@ public function new() { // Check for create permission if (! $this->model->mayCreate()) { - return $this->failure(403, lang('Permits.notPermitted')); + return $this->failure(403, lang('Files.notPermitted')); } return view('Tatter\Files\Views\new'); @@ -248,7 +250,7 @@ public function delete($fileId) return $this->failure(400, lang('Files.noFile')); } if (! $this->model->mayDelete($file)) { - return $this->failure(403, lang('Permits.notPermitted')); + return $this->failure(403, lang('Files.notPermitted')); } if ($this->model->delete($fileId)) { @@ -296,20 +298,21 @@ public function bulk(): ResponseInterface } // Bulk export of some kind, match the handler - if (! $handler = handlers('Exports')->where(['slug' => $action])->first()) { - return $this->failure(400, 'No handler found for ' . $action); + try { + $handler = ExporterFactory::find($action); + } catch (RuntimeException $e) { + return $this->failure(400, 'No export handler found for ' . $action); } - - $export = new $handler(); + $exporter = new $handler(); foreach ($fileIds as $fileId) { if ($file = $this->model->find($fileId)) { - $export->setFile($file->object->setBasename($file->filename)); + $exporter->setFile($file->object->setBasename($file->filename)); } } try { - $result = $export->process(); + $result = $exporter->process(); } catch (ExportsException $e) { return $this->failure(400, $e->getMessage()); } @@ -328,7 +331,7 @@ public function upload() { // Check for create permission if (! $this->model->mayCreate()) { - return $this->failure(403, lang('Permits.notPermitted')); + return $this->failure(403, lang('Files.notPermitted')); } // Verify upload succeeded @@ -394,7 +397,13 @@ public function upload() $data['clientname'] ??= $upload->getClientName(); // Accept the file - $file = $this->model->createFromPath($path ?? $upload->getRealPath(), $data); + try { + $file = $this->model->createFromPath($path ?? $upload->getRealPath(), $data); + } catch (Throwable $e) { + log_message('error', $e->getMessage()); + + return $this->failure(400, $e->getMessage()); + } // Trigger the Event with the new File Events::trigger('upload', $file); @@ -411,22 +420,21 @@ public function upload() /** * Processes Export requests. * - * @param string $slug The slug to match to Exports attribute * @param int|string $fileId */ - public function export(string $slug, $fileId): ResponseInterface + public function export(string $handlerId, $fileId): ResponseInterface { // Match the export handler - $handler = handlers('Exports')->where(['slug' => $slug])->first(); - if (empty($handler)) { - alert('warning', 'No handler found for ' . $slug); + try { + $handler = ExporterFactory::find($handlerId); + } catch (RuntimeException $e) { + alert('warning', 'No export handler found for ' . $handlerId); return redirect()->back(); } // Load the file - $file = $this->model->find($fileId); - if (empty($file)) { + if (empty($fileId) || null === $file = $this->model->find($fileId)) { alert('warning', lang('Files.noFile')); return redirect()->back(); @@ -442,21 +450,22 @@ public function export(string $slug, $fileId): ResponseInterface // Create the record model(ExportModel::class)->insert([ - 'handler' => $slug, + 'handler' => $handlerId, 'file_id' => $file->id, 'user_id' => user_id(), ]); // Pass to the handler - $export = new $handler($file->object); - $response = $export->setFilename($file->filename)->process(); + $exporter = new $handler($file->object); + $exporter->setFilename($file->filename); - // If the handler returned a response then we're done - if ($response instanceof ResponseInterface) { - return $response; + try { + $result = $exporter->process(); + } catch (ExportsException $e) { + return $this->failure(400, $e->getMessage()); } - return redirect()->back(); + return $result; } /** @@ -466,9 +475,13 @@ public function export(string $slug, $fileId): ResponseInterface */ public function thumbnail($fileId): ResponseInterface { - $path = ($file = $this->model->find($fileId)) ? $file->getThumbnail() : File::locateDefaultThumbnail(); + $path = ($file = $this->model->find($fileId)) + ? $file->getThumbnail() + : File::locateDefaultThumbnail(); - return $this->response->setHeader('Content-type', 'image/jpeg')->setBody(file_get_contents($path)); + return $this->response + ->setHeader('Content-type', 'image/jpeg') + ->setBody(file_get_contents($path)); } /** @@ -510,9 +523,26 @@ protected function setData(array $data, bool $overwrite = false): self */ protected function setDefaults(): self { + // Get bulk support and index Exporters by the extension(s) they support + $bulks = []; + $exporters = []; + + foreach (ExporterFactory::findAll() as $handler) { + $attributes = $handler::attributes(); + + if ($attributes['bulk']) { + $bulks[] = $handler; + } + + foreach ($attributes['extensions'] as $extension) { + $exporters[$extension][] = $attributes; + } + } + $this->setData([ 'source' => 'index', 'layout' => 'files', + 'model' => $this->model, 'files' => null, 'selected' => explode(',', $this->request->getVar('selected') ?? ''), 'userId' => null, @@ -522,8 +552,8 @@ protected function setDefaults(): self 'page' => $this->request->getVar('page'), 'pager' => null, 'access' => $this->model->mayAdmin() ? 'manage' : 'display', - 'exports' => $this->getExports(), - 'bulks' => handlers()->where(['bulk' => 1])->findAll(), + 'exports' => $exporters, + 'bulks' => $bulks, ]); // Add preferences @@ -563,27 +593,4 @@ protected function setPreferences(): self return $this; } - - /** - * Gets Export handlers indexed by the extension they support. - * - * @return array - */ - protected function getExports(): array - { - $exports = []; - - foreach (handlers('Exports')->findAll() as $class) { - $attributes = handlers()->getAttributes($class); - - // Add the class name for easy access later - $attributes['class'] = $class; - - foreach (explode(',', $attributes['extensions']) as $extension) { - $exports[$extension][] = $attributes; - } - } - - return $exports; - } } diff --git a/src/Entities/File.php b/src/Entities/File.php index fba67cd..0155f71 100644 --- a/src/Entities/File.php +++ b/src/Entities/File.php @@ -14,6 +14,9 @@ class File extends Entity 'updated_at', 'deleted_at', ]; + protected $attributes = [ + 'thumbnail' => '', + ]; /** * Resolved path to the default thumbnail @@ -92,38 +95,13 @@ public function getObject(): ?FileObject } } - /** - * Returns class names of Exports applicable to this file's extension - * - * @param bool $asterisk Whether to include generic "*" extensions - * - * @return string[] - */ - public function getExports($asterisk = true): array - { - $exports = []; - - if ($extension = $this->getExtension()) { - $exports = handlers('Exports')->where(['extensions has' => $extension])->findAll(); - } - - if ($asterisk) { - $exports = array_merge( - $exports, - handlers('Exports')->where(['extensions' => '*'])->findAll() - ); - } - - return $exports; - } - /** * Returns the path to this file's thumbnail, or the default from config. * Should always return a path to a valid file to be safe for img_data() */ public function getThumbnail(): string { - $path = config('Files')->getPath() . 'thumbnails' . DIRECTORY_SEPARATOR . ($this->attributes['thumbnail'] ?? ''); + $path = config('Files')->getPath() . 'thumbnails' . DIRECTORY_SEPARATOR . $this->attributes['thumbnail']; if (! is_file($path)) { $path = self::locateDefaultThumbnail(); diff --git a/src/Language/en/Files.php b/src/Language/en/Files.php index f9b8dea..42ffdb4 100644 --- a/src/Language/en/Files.php +++ b/src/Language/en/Files.php @@ -4,6 +4,7 @@ return [ // Exceptions + 'notPermitted' => 'You do not have permission to do that.', 'noAuth' => 'Missing dependency: authentication function user_id()', 'dirFail' => 'Unable to create storage directory: {0}', 'chunkDirFail' => 'Unable to create directory for chunk uploads: {0}', diff --git a/src/Models/FileModel.php b/src/Models/FileModel.php index 5f1964e..73229d9 100644 --- a/src/Models/FileModel.php +++ b/src/Models/FileModel.php @@ -8,6 +8,7 @@ use Faker\Generator; use Tatter\Files\Entities\File; use Tatter\Permits\Traits\PermitsTrait; +use Tatter\Thumbnails\Factories\ThumbnailerFactory; use Throwable; class FileModel extends Model @@ -92,7 +93,6 @@ public function createFromFile(CIFile $file, array $data = []): File // Gather file info $row = [ 'filename' => $file->getFilename(), - 'localname' => $file->getRandomName(), 'clientname' => $file->getFilename(), 'type' => Mimes::guessTypeFromExtension($file->getExtension()) ?? $file->getMimeType(), 'size' => $file->getSize(), @@ -108,9 +108,10 @@ public function createFromFile(CIFile $file, array $data = []): File // Determine if we need to move the file if (strpos($filePath, $storage) === false) { // Move the file - $file = $file->move($storage, $row['localname']); - chmod($storage . $row['localname'], 0664); + $file = $file->move($storage, $file->getRandomName()); + chmod((string) $file, 0664); } + $row['localname'] = $file->getFilename(); // Record it in the database $fileId = $this->insert($row); @@ -120,32 +121,40 @@ public function createFromFile(CIFile $file, array $data = []): File $this->addToUser($fileId, $userId); } - // Check for a thumbnail handler - $extension = pathinfo((string) $file, PATHINFO_EXTENSION); - if (service('thumbnails')->matchHandlers($extension) !== []) { - // Try to create a Thumbnail - $thumbnail = pathinfo($row['localname'], PATHINFO_FILENAME); - $output = $storage . 'thumbnails' . DIRECTORY_SEPARATOR . $thumbnail; - - try { - service('thumbnails')->create((string) $file, $output); - - // If it succeeds then update the database - $this->update($fileId, [ - 'thumbnail' => $thumbnail, - ]); - } catch (Throwable $e) { - log_message('error', $e->getMessage()); - log_message('error', 'Unable to create thumbnail for ' . $row['filename']); - - if (ENVIRONMENT === 'testing') { - throw $e; + $entity = $this->find($fileId); + + // Get the extension + if ($extension = $entity->getExtension()) { + // Check for a thumbnail handler + if (ThumbnailerFactory::findForExtension($extension) !== []) { + // Try to create a Thumbnail + $thumbnail = pathinfo($row['localname'], PATHINFO_FILENAME); + $output = $storage . 'thumbnails' . DIRECTORY_SEPARATOR . $thumbnail; + + try { + $result = service('thumbnails')->create($entity->getPath()); + copy($result, $output); + + // If it succeeds then update the database + $entity->thumbnail = $thumbnail; + $this->update($entity->id, [ + 'thumbnail' => $thumbnail, + ]); + } catch (Throwable $e) { + log_message('error', $e->getMessage()); + log_message('error', 'Unable to create thumbnail for ' . $row['filename']); + + if (ENVIRONMENT === 'testing') { + throw $e; + } } + } else { + log_message('debug', 'No thumbnail handler located for extension ' . $extension); } } // Return the File entity - return $this->find($fileId); + return $entity; } /** diff --git a/src/Publishers/DropzonePublisher.php b/src/Publishers/DropzonePublisher.php index 307d151..64c4f7a 100644 --- a/src/Publishers/DropzonePublisher.php +++ b/src/Publishers/DropzonePublisher.php @@ -6,14 +6,8 @@ class DropzonePublisher extends FrontendPublisher { - protected $source = 'vendor/enyo/dropzone/dist'; - - /** - * Destination path relative to AssetsConfig::directory. - * - * @see FrontendPublisher::__construct() - */ - protected $path = 'dropzone'; + protected string $vendorPath = 'enyo/dropzone/dist'; + protected string $publicPath = 'dropzone'; /** * Reads files from the sources and copies them out to their destinations. diff --git a/src/Views/Dropzone/config.php b/src/Views/Dropzone/config.php index d9ed566..c404c21 100644 --- a/src/Views/Dropzone/config.php +++ b/src/Views/Dropzone/config.php @@ -1,5 +1,7 @@ diff --git a/src/Views/Menus/bulk.php b/src/Views/Menus/bulk.php index 3ce8030..0314b8b 100644 --- a/src/Views/Menus/bulk.php +++ b/src/Views/Menus/bulk.php @@ -13,13 +13,13 @@ - - + + - ajax): ?> - + + - + diff --git a/src/Views/Menus/single.php b/src/Views/Menus/single.php index 0fbb8c8..d20d28a 100644 --- a/src/Views/Menus/single.php +++ b/src/Views/Menus/single.php @@ -1,8 +1,12 @@ getExports(); + +use Tatter\Exports\Factories\ExporterFactory; + +// Gather applicable exporters +$exporters = ExporterFactory::getAttributesForExtension($file->getExtension()); // Make sure there is something to display -if (empty($exports) && $access === 'display') { +if ($exporters === [] && $access === 'display') { return; } ?> @@ -11,16 +15,23 @@ diff --git a/src/Views/index.php b/src/Views/index.php index 17e7b4f..7a4fcd1 100644 --- a/src/Views/index.php +++ b/src/Views/index.php @@ -8,6 +8,9 @@
+ + mayCreate()): ?> + + +

Files

@@ -35,10 +40,14 @@
+ mayCreate()): ?>

You have no files! Would you like to add some now?

+ +

No files to display.

+ diff --git a/tests/_support/TestCase.php b/tests/_support/TestCase.php index 5764bb8..7f8c0b6 100644 --- a/tests/_support/TestCase.php +++ b/tests/_support/TestCase.php @@ -30,10 +30,7 @@ abstract class TestCase extends CIUnitTestCase */ protected string $testPath; - /** - * @var FileModel - */ - protected $model; + protected FileModel $model; public static function setUpBeforeClass(): void { diff --git a/tests/feature/DisplayTest.php b/tests/feature/DisplayTest.php index eacd523..0cb63da 100644 --- a/tests/feature/DisplayTest.php +++ b/tests/feature/DisplayTest.php @@ -16,7 +16,7 @@ public function testNoFiles() $result = $this->get('files'); $result->assertStatus(200); - $result->assertSee('You have no files'); + $result->assertSee('No files to display'); } public function testDefaultDisplaysCards() diff --git a/tests/feature/PermissionsTest.php b/tests/feature/PermissionsTest.php index 53af0a3..36d734e 100644 --- a/tests/feature/PermissionsTest.php +++ b/tests/feature/PermissionsTest.php @@ -65,7 +65,7 @@ public function testDenyListRedirects() $result = $this->get('files'); $result->assertStatus(302); - $result->assertSessionHas('error', lang('Permits.notPermitted')); + $result->assertSessionHas('error', lang('Files.notPermitted')); } public function testDenyAjaxReturnsError() @@ -76,7 +76,7 @@ public function testDenyAjaxReturnsError() $result = $this->withHeaders(['X-Requested-With' => 'XMLHttpRequest'])->get('files'); $result->assertStatus(403); - $result->assertJSONFragment(['error' => lang('Permits.notPermitted')]); + $result->assertJSONFragment(['error' => lang('Files.notPermitted')]); } public function testUploadMissingFile()