From 0049adff23dd2beb0f39d6c4b9281b87d3a7c6f6 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 30 Nov 2021 14:58:17 +0000 Subject: [PATCH] Apply coding standard --- examples/Files.php | 100 +- src/Config/Files.php | 96 +- src/Config/Registrar.php | 31 +- src/Config/Routes.php | 24 +- src/Controllers/Files.php | 1495 ++++++++--------- .../20190724212056_create_table_files.php | 100 +- .../20210119154813_create_exports.php | 90 +- src/Database/Seeds/FileSeeder.php | 94 +- src/Entities/File.php | 293 ++-- src/Exceptions/FilesException.php | 46 +- src/Helpers/files_helper.php | 153 +- src/Language/en/Files.php | 24 +- src/Models/ExportModel.php | 36 +- src/Models/FileModel.php | 382 ++--- src/Structures/FileObject.php | 66 +- src/Views/Dropzone/config.php | 18 +- src/Views/Formats/select.php | 2 +- src/Views/Menus/bulk.php | 5 +- src/Views/Menus/single.php | 11 +- src/Views/index.php | 2 +- src/Views/messages.php | 14 +- tests/_support/Fakers/FileFaker.php | 38 +- tests/_support/FeatureTestCase.php | 105 +- tests/_support/FilesTestCase.php | 261 +-- tests/_support/Models/UserModel.php | 26 +- tests/_support/app/Controllers/Widgets.php | 22 +- tests/_support/app/Models/FileModel.php | 4 +- tests/feature/DisplayTest.php | 317 ++-- tests/feature/PermissionsTest.php | 465 ++--- tests/feature/UserTest.php | 51 +- tests/unit/ControllerTest.php | 343 ++-- tests/unit/EntityTest.php | 32 +- tests/unit/FileObjectTest.php | 59 +- tests/unit/HelperTest.php | 104 +- tests/unit/ModelTest.php | 133 +- tests/unit/SeederTest.php | 44 +- 36 files changed, 2492 insertions(+), 2594 deletions(-) diff --git a/examples/Files.php b/examples/Files.php index 0955538..73bdf2e 100644 --- a/examples/Files.php +++ b/examples/Files.php @@ -1,6 +1,8 @@ - - */ - public $layouts = [ - 'public' => 'Tatter\Files\Views\layout', - 'manage' => 'Tatter\Files\Views\layout', - ]; - - /** - * View file aliases - * - * @var string[] - */ - public $views = [ - 'dropzone' => 'Tatter\Files\Views\Dropzone\config', - ]; - - /** - * Default display format; built in are 'cards', 'list', 'select' - * - * @var string - */ - public $defaultFormat = 'cards'; - - /** - * Path to the default thumbnail file - * - * @var string - */ - public $defaultThumbnail = 'Tatter\Files\Assets\Unavailable.jpg'; + /** + * Directory to store files (with trailing slash) + * + * @var string + */ + public $storagePath = WRITEPATH . 'files/'; + + /** + * Whether to include routes to the Files Controller. + * + * @var bool + */ + public $routeFiles = true; + + /** + * Layouts to use for general access and for administration + * + * @var array + */ + public $layouts = [ + 'public' => 'Tatter\Files\Views\layout', + 'manage' => 'Tatter\Files\Views\layout', + ]; + + /** + * View file aliases + * + * @var string[] + */ + public $views = [ + 'dropzone' => 'Tatter\Files\Views\Dropzone\config', + ]; + + /** + * Default display format; built in are 'cards', 'list', 'select' + * + * @var string + */ + public $defaultFormat = 'cards'; + + /** + * Path to the default thumbnail file + * + * @var string + */ + public $defaultThumbnail = 'Tatter\Files\Assets\Unavailable.jpg'; } diff --git a/src/Config/Files.php b/src/Config/Files.php index 950d94c..d94cb10 100644 --- a/src/Config/Files.php +++ b/src/Config/Files.php @@ -1,53 +1,55 @@ - - */ - public $layouts = [ - 'public' => 'Tatter\Files\Views\layout', - 'manage' => 'Tatter\Files\Views\layout', - ]; - - /** - * View file aliases - * - * @var string[] - */ - public $views = [ - 'dropzone' => 'Tatter\Files\Views\Dropzone\config', - ]; - - /** - * Default display format; built in are 'cards', 'list', 'select' - * - * @var string - */ - public $defaultFormat = 'cards'; - - /** - * Path to the default thumbnail file - * - * @var string - */ - public $defaultThumbnail = 'Tatter\Files\Assets\Unavailable.jpg'; + /** + * Directory to store files (with trailing slash) + * + * @var string + */ + public $storagePath = WRITEPATH . 'files/'; + + /** + * Whether to include routes to the Files Controller. + * + * @var bool + */ + public $routeFiles = true; + + /** + * Layouts to use for general access and for administration + * + * @var array + */ + public $layouts = [ + 'public' => 'Tatter\Files\Views\layout', + 'manage' => 'Tatter\Files\Views\layout', + ]; + + /** + * View file aliases + * + * @var string[] + */ + public $views = [ + 'dropzone' => 'Tatter\Files\Views\Dropzone\config', + ]; + + /** + * Default display format; built in are 'cards', 'list', 'select' + * + * @var string + */ + public $defaultFormat = 'cards'; + + /** + * Path to the default thumbnail file + * + * @var string + */ + public $defaultThumbnail = 'Tatter\Files\Assets\Unavailable.jpg'; } diff --git a/src/Config/Registrar.php b/src/Config/Registrar.php index 45703f7..01040e9 100644 --- a/src/Config/Registrar.php +++ b/src/Config/Registrar.php @@ -1,24 +1,25 @@ - [ - 'files_bootstrap' => 'Tatter\Files\Views\pager', - ], - ]; - } + /** + * Override database config + * + * @return array + */ + public static function Pager() + { + return [ + 'templates' => [ + 'files_bootstrap' => 'Tatter\Files\Views\pager', + ], + ]; + } } diff --git a/src/Config/Routes.php b/src/Config/Routes.php index 20305b6..fa3a7f6 100644 --- a/src/Config/Routes.php +++ b/src/Config/Routes.php @@ -1,21 +1,19 @@ routeFiles)) -{ - return; +if (empty(config('Files')->routeFiles)) { + return; } // Routes to Files controller -$routes->group('files', ['namespace' => '\Tatter\Files\Controllers'], function ($routes) -{ - $routes->get('/', 'Files::index'); - $routes->get('user', 'Files::user'); - $routes->get('user/(:any)', 'Files::user/$1'); - $routes->get('delete/(:num)', 'Files::delete/$1'); - $routes->get('thumbnail/(:num)', 'Files::thumbnail/$1'); +$routes->group('files', ['namespace' => '\Tatter\Files\Controllers'], static function ($routes) { + $routes->get('/', 'Files::index'); + $routes->get('user', 'Files::user'); + $routes->get('user/(:any)', 'Files::user/$1'); + $routes->get('delete/(:num)', 'Files::delete/$1'); + $routes->get('thumbnail/(:num)', 'Files::thumbnail/$1'); - $routes->post('upload', 'Files::upload'); - $routes->add('export/(:any)', 'Files::export/$1'); + $routes->post('upload', 'Files::upload'); + $routes->add('export/(:any)', 'Files::export/$1'); - $routes->add('(:any)', 'Files::$1'); + $routes->add('(:any)', 'Files::$1'); }); diff --git a/src/Controllers/Files.php b/src/Controllers/Files.php index 1a9fb55..06370bd 100644 --- a/src/Controllers/Files.php +++ b/src/Controllers/Files.php @@ -1,4 +1,6 @@ -config = $config ?? config('Files'); - - // Use the short model name so a child may be loaded first - $this->model = $model ?? model('FileModel'); // @phpstan-ignore-line - - // Verify the storage directory - FileModel::storage(); - } - - /** - * Verify authentication is configured correctly *after* parent calls loadHelpers(). - * - * @param RequestInterface $request - * @param ResponseInterface $response - * @param \Psr\Log\LoggerInterface $logger - * - * @throws \CodeIgniter\HTTP\Exceptions\HTTPException - * @see https://codeigniter4.github.io/CodeIgniter4/extending/authentication.html - */ - public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger) - { - parent::initController($request, $response, $logger); - - if (! function_exists('user_id') || ! empty($this->config->failNoAuth)) - { - throw new FilesException(lang('Files.noAuth')); - } - } - - //-------------------------------------------------------------------- - - /** - * Handles the final display of files based on $data. - * - * @return string - */ - public function display(): string - { - // Apply any defaults for missing metadata - $this->setDefaults(); - - // Get the Files - if (! isset($this->data['files'])) - { - // Apply a target user - if ($this->data['userId']) - { - $this->model->whereUser($this->data['userId']); - } - - // Apply any requested search filters - if ($this->data['search']) - { - $this->model->like('filename', $this->data['search']); - } - - // Sort and order - $this->model->orderBy($this->data['sort'], $this->data['order']); - - // Paginate non-select formats - if ($this->data['format'] !== 'select') - { - $this->setData([ - 'files' => $this->model->paginate($this->data['perPage'], 'default', $this->data['page']), - 'pager' => $this->model->pager, - ], true); - } - else - { - $this->setData([ - 'files' => $this->model->findAll() - ], true); - } - } - - // AJAX calls skip the wrapping - if ($this->data['ajax']) - { - return view('Tatter\Files\Views\Formats\\' . $this->data['format'], $this->data); - } - - return view('Tatter\Files\Views\index', $this->data); - } - - //-------------------------------------------------------------------- - - /** - * Lists of files; if global listing is not permitted then - * falls back to user(). - * - * @return RedirectResponse|string - */ - public function index() - { - // Check for list permission - if (! $this->model->mayList()) - { - return $this->user(); - } - - return $this->display(); - } - - /** - * Filters files for a user (defaults to the current user). - * - * @param string|integer|null $userId ID of the target user - * - * @return ResponseInterface|ResponseInterface|string - */ - public function user($userId = null) - { - // Figure out user & access - $userId = $userId ?? user_id() ?? 0; - - // Not logged in - if (! $userId) - { - // Check for list permission - if (! $this->model->mayList()) - { - return $this->failure(403, lang('Permits.notPermitted')); - } - - $this->setData([ - 'access' => 'display', - 'title' => 'All Files', - 'username' => '', - ]); - } - // Logged in, looking at another user - elseif ($userId != user_id()) - { - // Check for list permission - if (! $this->model->mayList()) - { - return $this->failure(403, lang('Permits.notPermitted')); - } - - $this->setData([ - 'access' => $this->model->mayAdmin() ? 'manage' : 'display', - 'title' => 'User Files', - 'username' => 'User', - ]); - } - // Looking at own files - else - { - $this->setData([ - 'access' => 'manage', - 'title' => 'My Files', - 'username' => 'My', - ]); - } - - $this->setData([ - 'userId' => $userId, - 'source' => 'user/' . $userId, - ]); - - return $this->display(); - } - - //-------------------------------------------------------------------- - - /** - * Display the Dropzone uploader. - * - * @return ResponseInterface|string - */ - public function new() - { - // Check for create permission - if (! $this->model->mayCreate()) - { - return $this->failure(403, lang('Permits.notPermitted')); - } - - return view('Tatter\Files\Views\new'); - } - - /** - * Displays or processes the form to rename a file. - * - * @param string|null $fileId - * - * @return ResponseInterface|string - */ - public function rename($fileId = null) - { - // Load the request - $fileId = $this->request->getGetPost('file_id') ?? $fileId; - $file = $this->model->find($fileId); - - // Handle missing info - if (empty($file)) - { - return $this->failure(400, lang('Files.noFile')); - } - - // Check for form submission - if ($filename = $this->request->getGetPost('filename')) - { - // Update the name - $file->filename = $filename; - $this->model->save($file); - - // AJAX requests are blank on success - return $this->request->isAJAX() - ? '' - : redirect()->back()->with('message', lang('Files.renameSuccess', [$filename])); - } - - // AJAX skips the wrapper - return view( - $this->request->isAJAX() ? 'Tatter\Files\Views\Forms\rename' : 'Tatter\Files\Views\rename', - [ - 'config' => $this->config, - 'file' => $file, - ] - ); - } - - /** - * Deletes a file. - * - * @param string $fileId - * - * @return ResponseInterface - */ - public function delete($fileId) - { - $file = $this->model->find($fileId); - if (empty($file)) - { - return $this->failure(400, lang('Files.noFile')); - } - if (! $this->model->mayDelete($file)) - { - return $this->failure(403, lang('Permits.notPermitted')); - } - - if ($this->model->delete($fileId)) - { - return redirect()->back()->with('message', lang('Files.deleteSuccess')); - } - - return $this->failure(400, implode('. ', $this->model->errors())); - } - - /** - * Handles bulk actions. - * - * @return ResponseInterface - */ - public function bulk(): ResponseInterface - { - // Load post data - $post = $this->request->getPost(); - - // Harvest file IDs and the requested action - $action = ''; - $fileIds = []; - foreach ($post as $key => $value) - { - if (is_numeric($value)) - { - $fileIds[] = $value; - } - else - { - $action = $key; - } - } - - // Make sure some files where checked - if (empty($fileIds)) - { - return $this->failure(400, lang('Files.noFile')); - } - - // Handle actions - if (empty($action)) - { - return $this->failure(400, 'No valid action'); - } - - // Bulk delete request - if ($action === 'delete') - { - $this->model->delete($fileIds); - return redirect()->back()->with('success', 'Deleted ' . count($fileIds) . ' files.'); - } - - // 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); - } - - $export = new $handler(); - foreach ($fileIds as $fileId) - { - if ($file = $this->model->find($fileId)) - { - $export->setFile($file->object->setBasename($file->filename)); - } - } - - try - { - $result = $export->process(); - } - catch (ExportsException $e) - { - return $this->failure(400, $e->getMessage()); - } - - alert('success', 'Processed ' . count($fileIds) . ' files.'); - return $result; - } - - /** - * Receives uploads from Dropzone. - * - * @return ResponseInterface|string - */ - public function upload() - { - // Check for create permission - if (! $this->model->mayCreate()) - { - return $this->failure(403, lang('Permits.notPermitted')); - } - - // Verify upload succeeded - $upload = $this->request->getFile('file'); - if (empty($upload)) - { - return $this->failure(400, lang('Files.noFile')); - } - if (! $upload->isValid()) - { - return $upload->getErrorString() . '(' . $upload->getError() . ')'; - } - - // Check for chunks - if ($this->request->getPost('chunkIndex') !== null) - { - // Gather chunk info - $chunkIndex = $this->request->getPost('chunkIndex'); - $totalChunks = $this->request->getPost('totalChunks'); - $uuid = $this->request->getPost('uuid'); - - // Check for chunk directory - $chunkDir = WRITEPATH . 'uploads/' . $uuid; - if (! is_dir($chunkDir) && ! mkdir($chunkDir, 0775, true)) - { - return $this->failure(400, lang('Files.chunkDirFail', [$chunkDir])); - } - - // Move the file - try - { - $upload->move($chunkDir, $chunkIndex . '.' . $upload->getExtension()); - } - catch (HTTPException $e) - { - log_message('error', $e->getMessage()); - return $this->failure(400, $e->getMessage()); - } - - // Check for more chunks - if ($chunkIndex < $totalChunks - 1) - { - session_write_close(); - return ''; - } - - // Merge the chunks - try - { - $path = $this->mergeChunks($chunkDir); - } - catch (FilesException $e) - { - log_message('error', $e->getMessage()); - return $this->failure(400, $e->getMessage()); - } - } - - // Get additional post data to pass to model - $data = $this->request->getPost(); - $data['filename'] = $data['filename'] ?? $upload->getClientName(); - $data['clientname'] = $data['clientname'] ?? $upload->getClientName(); - - // Accept the file - $file = $this->model->createFromPath($path ?? $upload->getRealPath(), $data); - - // Trigger the Event with the new File - Events::trigger('upload', $file); - - if ($this->request->isAJAX()) - { - session_write_close(); - return ''; - } - - return redirect()->back()->with('message', lang('File.uploadSucces', [$file->clientname])); - } - - /** - * Merges all chunks in a target directory into a single file, returns the file path. - * - * @return string - * - * @throws FilesException - */ - protected function mergeChunks($dir): string - { - helper('filesystem'); - helper('text'); - - // Get chunks from target directory - $chunks = get_filenames($dir, true); - if (empty($chunks)) - { - throw FilesException::forNoChunks($dir); - } - - // Create the temp file - $tmpfile = tempnam(sys_get_temp_dir(), random_string()); - log_message('debug', 'Merging ' . count($chunks) . ' chunks to ' . $tmpfile); - - // Open temp file for writing - $output = @fopen($tmpfile, 'ab'); - if (! $output) - { - throw FilesException::forNewFileFail($tmpfile); - } - - // Write each chunk to the temp file - foreach ($chunks as $file) - { - $input = @fopen($file, 'rb'); - if (! $input) - { - throw FilesException::forWriteFileFail($tmpfile); - } - - // Buffered merge of chunk - while ($buffer = fread($input, 4096)) - { - fwrite($output, $buffer); - } - - fclose($input); - } - - // close output handle - fclose($output); - - return $tmpfile; - } - - /** - * Processes Export requests. - * - * @param string $slug The slug to match to Exports attribute - * @param string|integer $fileId - * - * @return ResponseInterface - */ - public function export(string $slug, $fileId): ResponseInterface - { - // Match the export handler - $handler = handlers('Exports')->where(['slug' => $slug])->first(); - if (empty($handler)) - { - alert('warning', 'No handler found for ' . $slug); - return redirect()->back(); - } - - // Load the file - $file = $this->model->find($fileId); - if (empty($file)) - { - alert('warning', lang('Files.noFile')); - return redirect()->back(); - } - - // Verify the file exists - if (! $fileObject = $file->getObject()) - { - log_message('error', lang('Files.fileNotFound', [$file->getPath()])); - alert('warning', lang('Files.fileNotFound', [$file->filename])); - return redirect()->back(); - } - - // Create the record - model(ExportModel::class)->insert([ - 'handler' => $slug, - 'file_id' => $file->id, - 'user_id' => user_id() ?: null - ]); - - // Pass to the handler - $export = new $handler($file->object); - $response = $export->setFilename($file->filename)->process(); - - // If the handler returned a response then we're done - if ($response instanceof ResponseInterface) - { - return $response; - } - - return redirect()->back(); - } - - /** - * Outputs a file thumbnail directly as image data. - * - * @param string|integer $fileId - * - * @return ResponseInterface - */ - public function thumbnail($fileId): ResponseInterface - { - if ($file = $this->model->find($fileId)) - { - $path = $file->getThumbnail(); - } - else - { - $path = File::locateDefaultThumbnail(); - } - - return $this->response->setHeader('Content-type', 'image/jpeg')->setBody(file_get_contents($path)); - } - - //-------------------------------------------------------------------- - - /** - * Handles failures. - * - * @param int $code - * @param string $message - * @param boolean|null $isAjax - * - * @return ResponseInterface|RedirectResponse - */ - protected function failure(int $code, string $message, bool $isAjax = null): ResponseInterface - { - log_message('debug', $message); - - if ($isAjax ?? $this->request->isAJAX()) - { - return $this->response - ->setStatusCode($code) - ->setJSON(['error' => $message]); - } - - return redirect()->back()->with('error', $message); - } - - /** - * Sets a value in $this->data, overwrites optional. - * - * @param array $data - * @param boolean $overwrite - * - * @return $this - */ - protected function setData(array $data, bool $overwrite = false): self - { - if ($overwrite) - { - $this->data = array_merge($this->data, $data); - } - else - { - $this->data = array_merge($data, $this->data); - } - - return $this; - } - - /** - * Merges in the default metadata. - * - * @return $this - */ - protected function setDefaults(): self - { - return $this->setData([ - 'source' => 'index', - 'layout' => 'public', - 'files' => null, - 'selected' => explode(',', $this->request->getVar('selected') ?? ''), - 'userId' => null, - 'username' => '', - 'ajax' => $this->request->isAJAX(), - 'search' => $this->request->getVar('search'), - 'sort' => $this->getSort(), - 'order' => $this->getOrder(), - 'format' => $this->getFormat(), - 'perPage' => $this->getPerPage(), - 'page' => $this->request->getVar('page'), - 'pager' => null, - 'access' => $this->model->mayAdmin() ? 'manage' : 'display', - 'exports' => $this->getExports(), - 'bulks' => handlers()->where(['bulk' => 1])->findAll(), - ]); - } - - /** - * Determines the sort field. - * - * @return string - */ - protected function getSort(): string - { - // Check for a request, then load from Settings - $sorts = [ - $this->request->getVar('sort'), - service('settings')->filesSort, - ]; - - foreach ($sorts as $sort) - { - // Validate - if (in_array($sort, $this->model->allowedFields)) // @phpstan-ignore-line - { - // Update user setting with the new preference - service('settings')->filesSort = $sort; - return $sort; - } - } - - return 'filename'; - } - - /** - * Determines the sort order. - * - * @return string - */ - protected function getOrder(): string - { - // Check for a request, then load from Settings - $orders = [ - $this->request->getVar('order'), - service('settings')->filesOrder, - ]; - - foreach ($orders as $order) - { - $order = strtolower($order); - - // Validate - if (in_array($order, ['asc', 'desc'])) - { - // Update user setting with the new preference - service('settings')->filesOrder = $order; - return $order; - } - } - - return 'asc'; - } - - /** - * Determines items per page. - * - * @return int - */ - protected function getPerPage(): int - { - // Check for a request, then load from Settings - $nums = [ - $this->request->getVar('perPage'), - service('settings')->perPage, - ]; - - foreach ($nums as $num) - { - // Validate - if (is_numeric($num) && (int) $num > 0) - { - // Update user setting with the new preference - service('settings')->perPage = $num; - return $num; - } - } - - return 10; - } - - /** - * Determines the display format. - * - * @return string - */ - protected function getFormat(): string - { - // Check for a request, then load from Settings, fallback to the config default - $formats = [ - $this->request->getVar('format'), - service('settings')->filesFormat, - $this->config->defaultFormat, - ]; - - foreach ($formats as $format) - { - // Validate - if (in_array($format, ['cards', 'list', 'select'])) - { - // Update user setting with the new preference - service('settings')->filesFormat = $format; - return $format; - } - } - - return 'cards'; - } - - /** - * 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; - } + /** + * Files config. + * + * @var FilesConfig + */ + protected $config; + + /** + * The model to use, may be a child of this library's. + * + * @var FileModel + */ + protected $model; + + /** + * Helpers to load. + */ + protected $helpers = ['alerts', 'files', 'handlers', 'html', 'text']; + + /** + * Overriding data for views. + * + * @var array + */ + protected $data = []; + + /** + * Preloads the configuration and verifies the storage directory. + * Parameters are mostly for testing purposes. + * + * @throws FilesException + */ + public function __construct(?FilesConfig $config = null, ?FileModel $model = null) + { + $this->config = $config ?? config('Files'); + + // Use the short model name so a child may be loaded first + $this->model = $model ?? model('FileModel'); // @phpstan-ignore-line + + // Verify the storage directory + FileModel::storage(); + } + + /** + * Verify authentication is configured correctly *after* parent calls loadHelpers(). + * + * @throws \CodeIgniter\HTTP\Exceptions\HTTPException + * + * @see https://codeigniter4.github.io/CodeIgniter4/extending/authentication.html + */ + public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger) + { + parent::initController($request, $response, $logger); + + if (! function_exists('user_id') || ! empty($this->config->failNoAuth)) { + throw new FilesException(lang('Files.noAuth')); + } + } + + //-------------------------------------------------------------------- + + /** + * Handles the final display of files based on $data. + */ + public function display(): string + { + // Apply any defaults for missing metadata + $this->setDefaults(); + + // Get the Files + if (! isset($this->data['files'])) { + // Apply a target user + if ($this->data['userId']) { + $this->model->whereUser($this->data['userId']); + } + + // Apply any requested search filters + if ($this->data['search']) { + $this->model->like('filename', $this->data['search']); + } + + // Sort and order + $this->model->orderBy($this->data['sort'], $this->data['order']); + + // Paginate non-select formats + if ($this->data['format'] !== 'select') { + $this->setData([ + 'files' => $this->model->paginate($this->data['perPage'], 'default', $this->data['page']), + 'pager' => $this->model->pager, + ], true); + } else { + $this->setData([ + 'files' => $this->model->findAll(), + ], true); + } + } + + // AJAX calls skip the wrapping + if ($this->data['ajax']) { + return view('Tatter\Files\Views\Formats\\' . $this->data['format'], $this->data); + } + + return view('Tatter\Files\Views\index', $this->data); + } + + //-------------------------------------------------------------------- + + /** + * Lists of files; if global listing is not permitted then + * falls back to user(). + * + * @return RedirectResponse|string + */ + public function index() + { + // Check for list permission + if (! $this->model->mayList()) { + return $this->user(); + } + + return $this->display(); + } + + /** + * Filters files for a user (defaults to the current user). + * + * @param int|string|null $userId ID of the target user + * + * @return ResponseInterface|ResponseInterface|string + */ + public function user($userId = null) + { + // Figure out user & access + $userId = $userId ?? user_id() ?? 0; + + // Not logged in + if (! $userId) { + // Check for list permission + if (! $this->model->mayList()) { + return $this->failure(403, lang('Permits.notPermitted')); + } + + $this->setData([ + 'access' => 'display', + 'title' => 'All Files', + 'username' => '', + ]); + } + // Logged in, looking at another user + elseif ($userId !== user_id()) { + // Check for list permission + if (! $this->model->mayList()) { + return $this->failure(403, lang('Permits.notPermitted')); + } + + $this->setData([ + 'access' => $this->model->mayAdmin() ? 'manage' : 'display', + 'title' => 'User Files', + 'username' => 'User', + ]); + } + // Looking at own files + else { + $this->setData([ + 'access' => 'manage', + 'title' => 'My Files', + 'username' => 'My', + ]); + } + + $this->setData([ + 'userId' => $userId, + 'source' => 'user/' . $userId, + ]); + + return $this->display(); + } + + //-------------------------------------------------------------------- + + /** + * Display the Dropzone uploader. + * + * @return ResponseInterface|string + */ + public function new() + { + // Check for create permission + if (! $this->model->mayCreate()) { + return $this->failure(403, lang('Permits.notPermitted')); + } + + return view('Tatter\Files\Views\new'); + } + + /** + * Displays or processes the form to rename a file. + * + * @param string|null $fileId + * + * @return ResponseInterface|string + */ + public function rename($fileId = null) + { + // Load the request + $fileId = $this->request->getGetPost('file_id') ?? $fileId; + $file = $this->model->find($fileId); + + // Handle missing info + if (empty($file)) { + return $this->failure(400, lang('Files.noFile')); + } + + // Check for form submission + if ($filename = $this->request->getGetPost('filename')) { + // Update the name + $file->filename = $filename; + $this->model->save($file); + + // AJAX requests are blank on success + return $this->request->isAJAX() + ? '' + : redirect()->back()->with('message', lang('Files.renameSuccess', [$filename])); + } + + // AJAX skips the wrapper + return view( + $this->request->isAJAX() ? 'Tatter\Files\Views\Forms\rename' : 'Tatter\Files\Views\rename', + [ + 'config' => $this->config, + 'file' => $file, + ] + ); + } + + /** + * Deletes a file. + * + * @param string $fileId + * + * @return ResponseInterface + */ + public function delete($fileId) + { + $file = $this->model->find($fileId); + if (empty($file)) { + return $this->failure(400, lang('Files.noFile')); + } + if (! $this->model->mayDelete($file)) { + return $this->failure(403, lang('Permits.notPermitted')); + } + + if ($this->model->delete($fileId)) { + return redirect()->back()->with('message', lang('Files.deleteSuccess')); + } + + return $this->failure(400, implode('. ', $this->model->errors())); + } + + /** + * Handles bulk actions. + */ + public function bulk(): ResponseInterface + { + // Load post data + $post = $this->request->getPost(); + + // Harvest file IDs and the requested action + $action = ''; + $fileIds = []; + + foreach ($post as $key => $value) { + if (is_numeric($value)) { + $fileIds[] = $value; + } else { + $action = $key; + } + } + + // Make sure some files where checked + if (empty($fileIds)) { + return $this->failure(400, lang('Files.noFile')); + } + + // Handle actions + if (empty($action)) { + return $this->failure(400, 'No valid action'); + } + + // Bulk delete request + if ($action === 'delete') { + $this->model->delete($fileIds); + + return redirect()->back()->with('success', 'Deleted ' . count($fileIds) . ' files.'); + } + + // 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); + } + + $export = new $handler(); + + foreach ($fileIds as $fileId) { + if ($file = $this->model->find($fileId)) { + $export->setFile($file->object->setBasename($file->filename)); + } + } + + try { + $result = $export->process(); + } catch (ExportsException $e) { + return $this->failure(400, $e->getMessage()); + } + + alert('success', 'Processed ' . count($fileIds) . ' files.'); + + return $result; + } + + /** + * Receives uploads from Dropzone. + * + * @return ResponseInterface|string + */ + public function upload() + { + // Check for create permission + if (! $this->model->mayCreate()) { + return $this->failure(403, lang('Permits.notPermitted')); + } + + // Verify upload succeeded + $upload = $this->request->getFile('file'); + if (empty($upload)) { + return $this->failure(400, lang('Files.noFile')); + } + if (! $upload->isValid()) { + return $upload->getErrorString() . '(' . $upload->getError() . ')'; + } + + // Check for chunks + if ($this->request->getPost('chunkIndex') !== null) { + // Gather chunk info + $chunkIndex = $this->request->getPost('chunkIndex'); + $totalChunks = $this->request->getPost('totalChunks'); + $uuid = $this->request->getPost('uuid'); + + // Check for chunk directory + $chunkDir = WRITEPATH . 'uploads/' . $uuid; + if (! is_dir($chunkDir) && ! mkdir($chunkDir, 0775, true)) { + return $this->failure(400, lang('Files.chunkDirFail', [$chunkDir])); + } + + // Move the file + try { + $upload->move($chunkDir, $chunkIndex . '.' . $upload->getExtension()); + } catch (HTTPException $e) { + log_message('error', $e->getMessage()); + + return $this->failure(400, $e->getMessage()); + } + + // Check for more chunks + if ($chunkIndex < $totalChunks - 1) { + session_write_close(); + + return ''; + } + + // Merge the chunks + try { + $path = $this->mergeChunks($chunkDir); + } catch (FilesException $e) { + log_message('error', $e->getMessage()); + + return $this->failure(400, $e->getMessage()); + } + } + + // Get additional post data to pass to model + $data = $this->request->getPost(); + $data['filename'] = $data['filename'] ?? $upload->getClientName(); + $data['clientname'] = $data['clientname'] ?? $upload->getClientName(); + + // Accept the file + $file = $this->model->createFromPath($path ?? $upload->getRealPath(), $data); + + // Trigger the Event with the new File + Events::trigger('upload', $file); + + if ($this->request->isAJAX()) { + session_write_close(); + + return ''; + } + + return redirect()->back()->with('message', lang('File.uploadSucces', [$file->clientname])); + } + + /** + * Merges all chunks in a target directory into a single file, returns the file path. + * + * @param mixed $dir + * + * @throws FilesException + */ + protected function mergeChunks($dir): string + { + helper('filesystem'); + helper('text'); + + // Get chunks from target directory + $chunks = get_filenames($dir, true); + if (empty($chunks)) { + throw FilesException::forNoChunks($dir); + } + + // Create the temp file + $tmpfile = tempnam(sys_get_temp_dir(), random_string()); + log_message('debug', 'Merging ' . count($chunks) . ' chunks to ' . $tmpfile); + + // Open temp file for writing + $output = @fopen($tmpfile, 'ab'); + if (! $output) { + throw FilesException::forNewFileFail($tmpfile); + } + + // Write each chunk to the temp file + foreach ($chunks as $file) { + $input = @fopen($file, 'rb'); + if (! $input) { + throw FilesException::forWriteFileFail($tmpfile); + } + + // Buffered merge of chunk + while ($buffer = fread($input, 4096)) { + fwrite($output, $buffer); + } + + fclose($input); + } + + // close output handle + fclose($output); + + return $tmpfile; + } + + /** + * Processes Export requests. + * + * @param string $slug The slug to match to Exports attribute + * @param int|string $fileId + */ + public function export(string $slug, $fileId): ResponseInterface + { + // Match the export handler + $handler = handlers('Exports')->where(['slug' => $slug])->first(); + if (empty($handler)) { + alert('warning', 'No handler found for ' . $slug); + + return redirect()->back(); + } + + // Load the file + $file = $this->model->find($fileId); + if (empty($file)) { + alert('warning', lang('Files.noFile')); + + return redirect()->back(); + } + + // Verify the file exists + if (! $fileObject = $file->getObject()) { + log_message('error', lang('Files.fileNotFound', [$file->getPath()])); + alert('warning', lang('Files.fileNotFound', [$file->filename])); + + return redirect()->back(); + } + + // Create the record + model(ExportModel::class)->insert([ + 'handler' => $slug, + 'file_id' => $file->id, + 'user_id' => user_id() ?: null, + ]); + + // Pass to the handler + $export = new $handler($file->object); + $response = $export->setFilename($file->filename)->process(); + + // If the handler returned a response then we're done + if ($response instanceof ResponseInterface) { + return $response; + } + + return redirect()->back(); + } + + /** + * Outputs a file thumbnail directly as image data. + * + * @param int|string $fileId + */ + public function thumbnail($fileId): ResponseInterface + { + if ($file = $this->model->find($fileId)) { + $path = $file->getThumbnail(); + } else { + $path = File::locateDefaultThumbnail(); + } + + return $this->response->setHeader('Content-type', 'image/jpeg')->setBody(file_get_contents($path)); + } + + //-------------------------------------------------------------------- + + /** + * Handles failures. + * + * @return RedirectResponse|ResponseInterface + */ + protected function failure(int $code, string $message, ?bool $isAjax = null): ResponseInterface + { + log_message('debug', $message); + + if ($isAjax ?? $this->request->isAJAX()) { + return $this->response + ->setStatusCode($code) + ->setJSON(['error' => $message]); + } + + return redirect()->back()->with('error', $message); + } + + /** + * Sets a value in $this->data, overwrites optional. + * + * @param array $data + * + * @return $this + */ + protected function setData(array $data, bool $overwrite = false): self + { + if ($overwrite) { + $this->data = array_merge($this->data, $data); + } else { + $this->data = array_merge($data, $this->data); + } + + return $this; + } + + /** + * Merges in the default metadata. + * + * @return $this + */ + protected function setDefaults(): self + { + return $this->setData([ + 'source' => 'index', + 'layout' => 'public', + 'files' => null, + 'selected' => explode(',', $this->request->getVar('selected') ?? ''), + 'userId' => null, + 'username' => '', + 'ajax' => $this->request->isAJAX(), + 'search' => $this->request->getVar('search'), + 'sort' => $this->getSort(), + 'order' => $this->getOrder(), + 'format' => $this->getFormat(), + 'perPage' => $this->getPerPage(), + 'page' => $this->request->getVar('page'), + 'pager' => null, + 'access' => $this->model->mayAdmin() ? 'manage' : 'display', + 'exports' => $this->getExports(), + 'bulks' => handlers()->where(['bulk' => 1])->findAll(), + ]); + } + + /** + * Determines the sort field. + */ + protected function getSort(): string + { + // Check for a request, then load from Settings + $sorts = [ + $this->request->getVar('sort'), + service('settings')->filesSort, + ]; + + foreach ($sorts as $sort) { + // Validate + if (in_array($sort, $this->model->allowedFields, true)) { // @phpstan-ignore-line + // Update user setting with the new preference + service('settings')->filesSort = $sort; + + return $sort; + } + } + + return 'filename'; + } + + /** + * Determines the sort order. + */ + protected function getOrder(): string + { + // Check for a request, then load from Settings + $orders = [ + $this->request->getVar('order'), + service('settings')->filesOrder, + ]; + + foreach ($orders as $order) { + $order = strtolower($order); + + // Validate + if (in_array($order, ['asc', 'desc'], true)) { + // Update user setting with the new preference + service('settings')->filesOrder = $order; + + return $order; + } + } + + return 'asc'; + } + + /** + * Determines items per page. + */ + protected function getPerPage(): int + { + // Check for a request, then load from Settings + $nums = [ + $this->request->getVar('perPage'), + service('settings')->perPage, + ]; + + foreach ($nums as $num) { + // Validate + if (is_numeric($num) && (int) $num > 0) { + // Update user setting with the new preference + service('settings')->perPage = $num; + + return $num; + } + } + + return 10; + } + + /** + * Determines the display format. + */ + protected function getFormat(): string + { + // Check for a request, then load from Settings, fallback to the config default + $formats = [ + $this->request->getVar('format'), + service('settings')->filesFormat, + $this->config->defaultFormat, + ]; + + foreach ($formats as $format) { + // Validate + if (in_array($format, ['cards', 'list', 'select'], true)) { + // Update user setting with the new preference + service('settings')->filesFormat = $format; + + return $format; + } + } + + return 'cards'; + } + + /** + * 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/Database/Migrations/20190724212056_create_table_files.php b/src/Database/Migrations/20190724212056_create_table_files.php index a5627de..ea28645 100644 --- a/src/Database/Migrations/20190724212056_create_table_files.php +++ b/src/Database/Migrations/20190724212056_create_table_files.php @@ -1,67 +1,69 @@ - ['type' => 'VARCHAR', 'constraint' => 255], - 'localname' => ['type' => 'VARCHAR', 'constraint' => 255], - 'clientname' => ['type' => 'VARCHAR', 'constraint' => 255], - 'type' => ['type' => 'VARCHAR', 'constraint' => 255], - 'size' => ['type' => 'INT', 'unsigned' => true], - 'thumbnail' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'created_at' => ['type' => 'DATETIME', 'null' => true], - 'updated_at' => ['type' => 'DATETIME', 'null' => true], - 'deleted_at' => ['type' => 'DATETIME', 'null' => true], - ]; + public function up() + { + // files + $fields = [ + 'filename' => ['type' => 'VARCHAR', 'constraint' => 255], + 'localname' => ['type' => 'VARCHAR', 'constraint' => 255], + 'clientname' => ['type' => 'VARCHAR', 'constraint' => 255], + 'type' => ['type' => 'VARCHAR', 'constraint' => 255], + 'size' => ['type' => 'INT', 'unsigned' => true], + 'thumbnail' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + 'deleted_at' => ['type' => 'DATETIME', 'null' => true], + ]; - $this->forge->addField('id'); - $this->forge->addField($fields); + $this->forge->addField('id'); + $this->forge->addField($fields); - $this->forge->addKey('filename'); - $this->forge->addKey('created_at'); + $this->forge->addKey('filename'); + $this->forge->addKey('created_at'); - $this->forge->createTable('files'); + $this->forge->createTable('files'); - // files_users - $fields = [ - 'file_id' => ['type' => 'INT', 'unsigned' => true], - 'user_id' => ['type' => 'INT', 'unsigned' => true], - 'created_at' => ['type' => 'DATETIME', 'null' => true], - ]; + // files_users + $fields = [ + 'file_id' => ['type' => 'INT', 'unsigned' => true], + 'user_id' => ['type' => 'INT', 'unsigned' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]; - $this->forge->addField('id'); - $this->forge->addField($fields); + $this->forge->addField('id'); + $this->forge->addField($fields); - $this->forge->addUniqueKey(['file_id', 'user_id']); - $this->forge->addUniqueKey(['user_id', 'file_id']); + $this->forge->addUniqueKey(['file_id', 'user_id']); + $this->forge->addUniqueKey(['user_id', 'file_id']); - $this->forge->createTable('files_users'); + $this->forge->createTable('files_users'); - // downloads - $fields = [ - 'file_id' => ['type' => 'INT', 'unsigned' => true], - 'user_id' => ['type' => 'INT', 'unsigned' => true], - 'created_at' => ['type' => 'DATETIME', 'null' => true], - ]; + // downloads + $fields = [ + 'file_id' => ['type' => 'INT', 'unsigned' => true], + 'user_id' => ['type' => 'INT', 'unsigned' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]; - $this->forge->addField('id'); - $this->forge->addField($fields); + $this->forge->addField('id'); + $this->forge->addField($fields); - $this->forge->addKey(['file_id', 'user_id']); - $this->forge->addKey(['user_id', 'file_id']); + $this->forge->addKey(['file_id', 'user_id']); + $this->forge->addKey(['user_id', 'file_id']); - $this->forge->createTable('downloads'); - } + $this->forge->createTable('downloads'); + } - public function down() - { - $this->forge->dropTable('files'); - $this->forge->dropTable('files_users'); - $this->forge->dropTable('downloads'); - } + public function down() + { + $this->forge->dropTable('files'); + $this->forge->dropTable('files_users'); + $this->forge->dropTable('downloads'); + } } diff --git a/src/Database/Migrations/20210119154813_create_exports.php b/src/Database/Migrations/20210119154813_create_exports.php index 8c9bd55..ac07b8f 100644 --- a/src/Database/Migrations/20210119154813_create_exports.php +++ b/src/Database/Migrations/20210119154813_create_exports.php @@ -1,50 +1,52 @@ -forge->dropTable('downloads'); - - // Create the exports table - $fields = [ - 'handler' => ['type' => 'varchar', 'constraint' => 63], - 'file_id' => ['type' => 'int', 'unsigned' => true], - 'user_id' => ['type' => 'int', 'unsigned' => true, 'null' => true], - 'created_at' => ['type' => 'datetime', 'null' => true], - ]; - - $this->forge->addField('id'); - $this->forge->addField($fields); - - $this->forge->addKey('handler'); - $this->forge->addKey('file_id'); - $this->forge->addKey('user_id'); - - $this->forge->createTable('exports'); - } - - public function down() - { - // Drop exports - $this->forge->dropTable('exports'); - - // Restore downloads table - $fields = [ - 'file_id' => ['type' => 'int', 'unsigned' => true], - 'user_id' => ['type' => 'int', 'unsigned' => true], - 'created_at' => ['type' => 'datetime', 'null' => true], - ]; - - $this->forge->addField('id'); - $this->forge->addField($fields); - - $this->forge->addKey(['file_id', 'user_id']); - $this->forge->addKey(['user_id', 'file_id']); - - $this->forge->createTable('downloads'); - } + public function up() + { + // Remove the unused downloads table + $this->forge->dropTable('downloads'); + + // Create the exports table + $fields = [ + 'handler' => ['type' => 'varchar', 'constraint' => 63], + 'file_id' => ['type' => 'int', 'unsigned' => true], + 'user_id' => ['type' => 'int', 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'datetime', 'null' => true], + ]; + + $this->forge->addField('id'); + $this->forge->addField($fields); + + $this->forge->addKey('handler'); + $this->forge->addKey('file_id'); + $this->forge->addKey('user_id'); + + $this->forge->createTable('exports'); + } + + public function down() + { + // Drop exports + $this->forge->dropTable('exports'); + + // Restore downloads table + $fields = [ + 'file_id' => ['type' => 'int', 'unsigned' => true], + 'user_id' => ['type' => 'int', 'unsigned' => true], + 'created_at' => ['type' => 'datetime', 'null' => true], + ]; + + $this->forge->addField('id'); + $this->forge->addField($fields); + + $this->forge->addKey(['file_id', 'user_id']); + $this->forge->addKey(['user_id', 'file_id']); + + $this->forge->createTable('downloads'); + } } diff --git a/src/Database/Seeds/FileSeeder.php b/src/Database/Seeds/FileSeeder.php index f700056..2d02bf3 100644 --- a/src/Database/Seeds/FileSeeder.php +++ b/src/Database/Seeds/FileSeeder.php @@ -1,54 +1,54 @@ - 'perPage', - 'datatype' => 'int', - 'summary' => 'Number of items to show per page', - 'content' => '10', - 'scope' => 'user', - 'protected' => 1, - ], - [ - 'name' => 'filesFormat', - 'datatype' => 'string', - 'summary' => 'Display format for listing files', - 'content' => 'cards', - 'scope' => 'user', - 'protected' => 0, - ], - [ - 'name' => 'filesSort', - 'datatype' => 'string', - 'summary' => 'Sort field for listing files', - 'content' => 'filename', - 'scope' => 'user', - 'protected' => 0, - ], - [ - 'name' => 'filesOrder', - 'datatype' => 'string', - 'summary' => 'Sort order for listing files', - 'content' => 'asc', - 'scope' => 'user', - 'protected' => 0, - ], - ]; + public function run() + { + // Compatible with Settings v1 and v2 + $templates = [ + [ + 'name' => 'perPage', + 'datatype' => 'int', + 'summary' => 'Number of items to show per page', + 'content' => '10', + 'scope' => 'user', + 'protected' => 1, + ], + [ + 'name' => 'filesFormat', + 'datatype' => 'string', + 'summary' => 'Display format for listing files', + 'content' => 'cards', + 'scope' => 'user', + 'protected' => 0, + ], + [ + 'name' => 'filesSort', + 'datatype' => 'string', + 'summary' => 'Sort field for listing files', + 'content' => 'filename', + 'scope' => 'user', + 'protected' => 0, + ], + [ + 'name' => 'filesOrder', + 'datatype' => 'string', + 'summary' => 'Sort order for listing files', + 'content' => 'asc', + 'scope' => 'user', + 'protected' => 0, + ], + ]; - // Check for each template and create it if it is missing - foreach ($templates as $template) - { - if (! model(SettingModel::class)->where('name', $template['name'])->first()) // @phpstan-ignore-line - { - model(SettingModel::class)->insert($template); - } - } - } + // Check for each template and create it if it is missing + foreach ($templates as $template) { + if (! model(SettingModel::class)->where('name', $template['name'])->first()) { // @phpstan-ignore-line + model(SettingModel::class)->insert($template); + } + } + } } diff --git a/src/Entities/File.php b/src/Entities/File.php index c151f0a..ad2d954 100644 --- a/src/Entities/File.php +++ b/src/Entities/File.php @@ -1,4 +1,6 @@ -defaultThumbnail; - $ext = pathinfo($path, PATHINFO_EXTENSION); - - if (! self::$defaultThumbnail = service('locator')->locateFile($path, null, $ext)) - { - throw new FileNotFoundException('Could not locate default thumbnail: ' . $path); - } - } - - return (string) self::$defaultThumbnail; - } - - //-------------------------------------------------------------------- - - /** - * Returns the full path to this file - * - * @return string - */ - public function getPath(): string - { - $path = config('Files')->storagePath . $this->attributes['localname']; - - return realpath($path) ?: $path; - } - - /** - * Returns the most likely actual file extension - * - * @param string $method Explicit method to use for determining the extension - * - * @return string - */ - public function getExtension($method = ''): string - { - if ($this->attributes['type'] !== 'application/octet-stream') - { - if (! $method || $method === 'type') - { - if ($extension = Mimes::guessExtensionFromType($this->attributes['type'])) - { - return $extension; - } - } - - if (! $method || $method === 'mime') - { - if ($file = $this->getObject()) - { - if ($extension = $file->guessExtension()) - { - return $extension; - } - } - } - } - - foreach (['clientname', 'localname', 'filename'] as $attribute) - { - if (! $method || $method === $attribute) - { - if ($extension = pathinfo($this->attributes[$attribute], PATHINFO_EXTENSION)) - { - return $extension; - } - } - } - - return ''; - } - - /** - * Returns a FileObject (CIFile/SplFileInfo) for the local file - * - * @return FileObject|null `null` for missing file - */ - public function getObject(): ?FileObject - { - try - { - return new FileObject($this->getPath(), true); - } - catch (FileNotFoundException $e) - { - return null; - } - } - - /** - * Returns class names of Exports applicable to this file's extension - * - * @param boolean $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() - * - * @return string - */ - public function getThumbnail(): string - { - $path = config('Files')->storagePath . 'thumbnails' . DIRECTORY_SEPARATOR . ($this->attributes['thumbnail'] ?? ''); - - if (! is_file($path)) - { - $path = self::locateDefaultThumbnail(); - } - - return realpath($path) ?: $path; - } + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + /** + * Resolved path to the default thumbnail + * + * @var string|null + */ + protected static $defaultThumbnail; + + /** + * Returns the absolute path to the configured default thumbnail + * + * @throws FileNotFoundException + */ + public static function locateDefaultThumbnail(): string + { + // If the path has not been resolved yet then try to now + if (null === self::$defaultThumbnail) { + $path = config('Files')->defaultThumbnail; + $ext = pathinfo($path, PATHINFO_EXTENSION); + + if (! self::$defaultThumbnail = service('locator')->locateFile($path, null, $ext)) { + throw new FileNotFoundException('Could not locate default thumbnail: ' . $path); + } + } + + return (string) self::$defaultThumbnail; + } + + //-------------------------------------------------------------------- + + /** + * Returns the full path to this file + */ + public function getPath(): string + { + $path = config('Files')->storagePath . $this->attributes['localname']; + + return realpath($path) ?: $path; + } + + /** + * Returns the most likely actual file extension + * + * @param string $method Explicit method to use for determining the extension + */ + public function getExtension($method = ''): string + { + if ($this->attributes['type'] !== 'application/octet-stream') { + if (! $method || $method === 'type') { + if ($extension = Mimes::guessExtensionFromType($this->attributes['type'])) { + return $extension; + } + } + + if (! $method || $method === 'mime') { + if ($file = $this->getObject()) { + if ($extension = $file->guessExtension()) { + return $extension; + } + } + } + } + + foreach (['clientname', 'localname', 'filename'] as $attribute) { + if (! $method || $method === $attribute) { + if ($extension = pathinfo($this->attributes[$attribute], PATHINFO_EXTENSION)) { + return $extension; + } + } + } + + return ''; + } + + /** + * Returns a FileObject (CIFile/SplFileInfo) for the local file + * + * @return FileObject|null `null` for missing file + */ + public function getObject(): ?FileObject + { + try { + return new FileObject($this->getPath(), true); + } catch (FileNotFoundException $e) { + return null; + } + } + + /** + * 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')->storagePath . 'thumbnails' . DIRECTORY_SEPARATOR . ($this->attributes['thumbnail'] ?? ''); + + if (! is_file($path)) { + $path = self::locateDefaultThumbnail(); + } + + return realpath($path) ?: $path; + } } diff --git a/src/Exceptions/FilesException.php b/src/Exceptions/FilesException.php index 81cbca3..3c00a05 100644 --- a/src/Exceptions/FilesException.php +++ b/src/Exceptions/FilesException.php @@ -1,31 +1,33 @@ - 1024) - { - $bytes /= 1024; - $unit = 'KB'; - } - if ($bytes > 1024) - { - $bytes /= 1024; - $unit = 'MB'; - } - if ($bytes > 1024) - { - $bytes /= 1024; - $unit = 'GB'; - } - if ($bytes > 1024) - { - $bytes /= 1024; - $unit = 'TB'; - } - if ($bytes > 1024) - { - $bytes /= 1024; - $unit = 'PB'; - } +if (! function_exists('bytes2human')) { + /** + * Converts bytes to a human-friendly format. + */ + function bytes2human(int $bytes): string + { + $unit = 'bytes'; + if ($bytes > 1024) { + $bytes /= 1024; + $unit = 'KB'; + } + if ($bytes > 1024) { + $bytes /= 1024; + $unit = 'MB'; + } + if ($bytes > 1024) { + $bytes /= 1024; + $unit = 'GB'; + } + if ($bytes > 1024) { + $bytes /= 1024; + $unit = 'TB'; + } + if ($bytes > 1024) { + $bytes /= 1024; + $unit = 'PB'; + } - return round($bytes, 1) . ' ' . $unit; - } + return round($bytes, 1) . ' ' . $unit; + } } -if (! function_exists('max_file_upload_in_bytes')) -{ - /** - * Determines the maximum allowed file size for uploads. - * Thanks to Thanks to AoEmaster (https://stackoverflow.com/users/1732818/aoemaster) - * - * @return integer - * @see https://stackoverflow.com/questions/2840755/how-to-determine-the-max-file-upload-limit-in-php - */ - function max_file_upload_in_bytes(): int - { - // Select maximum upload size - $max_upload = return_bytes(ini_get('upload_max_filesize')); +if (! function_exists('max_file_upload_in_bytes')) { + /** + * Determines the maximum allowed file size for uploads. + * Thanks to Thanks to AoEmaster (https://stackoverflow.com/users/1732818/aoemaster) + * + * @see https://stackoverflow.com/questions/2840755/how-to-determine-the-max-file-upload-limit-in-php + */ + function max_file_upload_in_bytes(): int + { + // Select maximum upload size + $max_upload = return_bytes(ini_get('upload_max_filesize')); - // Select post limit - $max_post = return_bytes(ini_get('post_max_size')); + // Select post limit + $max_post = return_bytes(ini_get('post_max_size')); - // Select memory limit - $memory_limit = return_bytes(ini_get('memory_limit')); + // Select memory limit + $memory_limit = return_bytes(ini_get('memory_limit')); - // Return the smallest of them, this defines the real limit - return min($max_upload, $max_post, $memory_limit); - } + // Return the smallest of them, this defines the real limit + return min($max_upload, $max_post, $memory_limit); + } } -if (! function_exists('return_bytes')) -{ - /** - * Converts ini-style sizes to bytes. - * - * @param string $value - * - * @return integer - */ - function return_bytes(string $value): int - { - $value = strtolower(trim($value)); - $unit = $value[strlen($value) - 1]; - $num = (int) rtrim($value, $unit); +if (! function_exists('return_bytes')) { + /** + * Converts ini-style sizes to bytes. + */ + function return_bytes(string $value): int + { + $value = strtolower(trim($value)); + $unit = $value[strlen($value) - 1]; + $num = (int) rtrim($value, $unit); - switch ($unit) - { - case 'g': $num *= 1024; - case 'm': $num *= 1024; - case 'k': $num *= 1024; + switch ($unit) { + case 'g': $num *= 1024; + // no break + case 'm': $num *= 1024; + // no break + case 'k': $num *= 1024; - // If it is not one of those modifiers then it was numerical bytes, add the final digit back - default: - $num = (int) ((string) $num . $unit); - } + // If it is not one of those modifiers then it was numerical bytes, add the final digit back + // no break + default: + $num = (int) ((string) $num . $unit); + } - return $num; - } + return $num; + } } diff --git a/src/Language/en/Files.php b/src/Language/en/Files.php index fab775f..a07424d 100644 --- a/src/Language/en/Files.php +++ b/src/Language/en/Files.php @@ -1,17 +1,17 @@ 'Missing dependency: authentication function user_id()', - 'dirFail' => 'Unable to create storage directory: {0}', - 'chunkDirFail' => 'Unable to create directory for chunk uploads: {0}', - 'noChunks' => 'No valid files found for chunk merge in: {0}', - 'noFile' => 'No file provided.', - 'newFileFail' => 'Unable to create file for merging: {0}', - 'writeFileFail' => 'Unable to open file for merging: {0}', + // Exceptions + 'noAuth' => 'Missing dependency: authentication function user_id()', + 'dirFail' => 'Unable to create storage directory: {0}', + 'chunkDirFail' => 'Unable to create directory for chunk uploads: {0}', + 'noChunks' => 'No valid files found for chunk merge in: {0}', + 'noFile' => 'No file provided.', + 'newFileFail' => 'Unable to create file for merging: {0}', + 'writeFileFail' => 'Unable to open file for merging: {0}', - 'uploadSuccess' => 'Upload of {0} was successful.', - 'exportSuccess' => '{0} export was successful.', - 'renameSuccess' => 'File renamed to {0}.', - 'deleteSuccess' => 'File deleted.', + 'uploadSuccess' => 'Upload of {0} was successful.', + 'exportSuccess' => '{0} export was successful.', + 'renameSuccess' => 'File renamed to {0}.', + 'deleteSuccess' => 'File deleted.', ]; diff --git a/src/Models/ExportModel.php b/src/Models/ExportModel.php index c76253a..6565a61 100644 --- a/src/Models/ExportModel.php +++ b/src/Models/ExportModel.php @@ -1,10 +1,9 @@ - 'required|max_length[63]', - 'file_id' => 'required|integer', - 'user_id' => 'permit_empty|integer', - ]; + protected $table = 'exports'; + protected $primaryKey = 'id'; + protected $returnType = 'object'; + protected $useTimestamps = true; + protected $updatedField = ''; + protected $useSoftDeletes = false; + protected $skipValidation = false; + protected $allowedFields = ['handler', 'file_id', 'user_id']; + protected $validationRules = [ + 'handler' => 'required|max_length[63]', + 'file_id' => 'required|integer', + 'user_id' => 'permit_empty|integer', + ]; } diff --git a/src/Models/FileModel.php b/src/Models/FileModel.php index 264b56b..e5e8930 100644 --- a/src/Models/FileModel.php +++ b/src/Models/FileModel.php @@ -1,215 +1,187 @@ - 'required|max_length[255]', - // file size in bytes - 'size' => 'permit_empty|is_natural', - ]; - - // Audits - protected $afterInsert = ['auditInsert']; - protected $afterUpdate = ['auditUpdate']; - protected $afterDelete = ['auditDelete']; - - // Permits - protected $mode = 04660; - protected $userKey = 'user_id'; - protected $pivotKey = 'file_id'; - protected $usersPivot = 'files_users'; - - //-------------------------------------------------------------------- - - /** - * Normalizes and creates (if necessary) the storage and thumbnail paths. - * - * @return string The normalized storage path - * - * @throws FilesException - */ - public static function storage(): string - { - // Normalize the path - $storage = realpath(config('Files')->storagePath) ?: config('Files')->storagePath; - $storage = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - if (! is_dir($storage) && ! @mkdir($storage, 0775, true)) - { - throw FilesException::forDirFail($storage); - } - - // Normalize the path - $thumbnails = $storage . 'thumbnails'; - if (! is_dir($thumbnails) && ! @mkdir($thumbnails, 0775, true)) - { - throw FilesException::forDirFail($thumbnails); // @codeCoverageIgnore - } - - return $storage; - } - - //-------------------------------------------------------------------- - - /** - * Associates a file with a user - * - * @param integer $fileId - * @param integer $userId - * - * @return boolean - */ - public function addToUser(int $fileId, int $userId): bool - { - return (bool) $this->db->table('files_users')->insert([ - 'file_id' => $fileId, - 'user_id' => $userId, - ]); - } - - /** - * Returns an array of all a user's Files - * - * @param integer $userId - * - * @return array - */ - public function getForUser(int $userId): array - { - return $this->whereUser($userId)->findAll(); - } - - /** - * Adds a where filter for a specific user. - * - * @param integer $userId - * - * @return $this - */ - public function whereUser(int $userId): self - { - $this->select('files.*') - ->join('files_users', 'files_users.file_id = files.id', 'left') - ->where('user_id', $userId); - - return $this; - } - - //-------------------------------------------------------------------- - - /** - * Creates a new File from a path File. See createFromFile(). - * - * @param string $path - * @param array $data Additional data to pass to insert() - * - * @return File - */ - public function createFromPath(string $path, array $data = []): File - { - return $this->createFromFile(new CIFile($path, true), $data); - } - - /** - * Creates a new File from a framework File. Adds it to the - * database and moves it into storage (if it is not already). - * - * @param CIFile $file - * @param array $data Additional data to pass to insert() - * - * @return File - */ - 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(), - ]; - - // Merge additional data - $row = array_merge($row, $data); - - // Normalize paths - $storage = self::storage(); - $filePath = $file->getRealPath() ?: (string) $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); - } - - // Record it in the database - $fileId = $this->insert($row); - - // If a user is logged in then associate the File - if ($userId = user_id()) - { - $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; - } - } - } - - // Return the File entity - return $this->find($fileId); - } + use \Tatter\Audits\Traits\AuditsTrait; + use \Tatter\Permits\Traits\PermitsTrait; + + protected $table = 'files'; + protected $primaryKey = 'id'; + protected $returnType = File::class; + protected $useTimestamps = true; + protected $useSoftDeletes = true; + protected $skipValidation = false; + protected $allowedFields = [ + 'filename', + 'localname', + 'clientname', + 'type', + 'size', + 'thumbnail', + ]; + protected $validationRules = [ + 'filename' => 'required|max_length[255]', + // file size in bytes + 'size' => 'permit_empty|is_natural', + ]; + + // Audits + protected $afterInsert = ['auditInsert']; + protected $afterUpdate = ['auditUpdate']; + protected $afterDelete = ['auditDelete']; + + // Permits + protected $mode = 04660; + protected $userKey = 'user_id'; + protected $pivotKey = 'file_id'; + protected $usersPivot = 'files_users'; + + //-------------------------------------------------------------------- + + /** + * Normalizes and creates (if necessary) the storage and thumbnail paths. + * + * @throws FilesException + * + * @return string The normalized storage path + */ + public static function storage(): string + { + // Normalize the path + $storage = realpath(config('Files')->storagePath) ?: config('Files')->storagePath; + $storage = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if (! is_dir($storage) && ! @mkdir($storage, 0775, true)) { + throw FilesException::forDirFail($storage); + } + + // Normalize the path + $thumbnails = $storage . 'thumbnails'; + if (! is_dir($thumbnails) && ! @mkdir($thumbnails, 0775, true)) { + throw FilesException::forDirFail($thumbnails); // @codeCoverageIgnore + } + + return $storage; + } + + //-------------------------------------------------------------------- + + /** + * Associates a file with a user + */ + public function addToUser(int $fileId, int $userId): bool + { + return (bool) $this->db->table('files_users')->insert([ + 'file_id' => $fileId, + 'user_id' => $userId, + ]); + } + + /** + * Returns an array of all a user's Files + */ + public function getForUser(int $userId): array + { + return $this->whereUser($userId)->findAll(); + } + + /** + * Adds a where filter for a specific user. + * + * @return $this + */ + public function whereUser(int $userId): self + { + $this->select('files.*') + ->join('files_users', 'files_users.file_id = files.id', 'left') + ->where('user_id', $userId); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Creates a new File from a path File. See createFromFile(). + * + * @param array $data Additional data to pass to insert() + */ + public function createFromPath(string $path, array $data = []): File + { + return $this->createFromFile(new CIFile($path, true), $data); + } + + /** + * Creates a new File from a framework File. Adds it to the + * database and moves it into storage (if it is not already). + * + * @param array $data Additional data to pass to insert() + */ + 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(), + ]; + + // Merge additional data + $row = array_merge($row, $data); + + // Normalize paths + $storage = self::storage(); + $filePath = $file->getRealPath() ?: (string) $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); + } + + // Record it in the database + $fileId = $this->insert($row); + + // If a user is logged in then associate the File + if ($userId = user_id()) { + $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; + } + } + } + + // Return the File entity + return $this->find($fileId); + } } diff --git a/src/Structures/FileObject.php b/src/Structures/FileObject.php index eafc8cd..b07efee 100644 --- a/src/Structures/FileObject.php +++ b/src/Structures/FileObject.php @@ -1,6 +1,7 @@ -basename = $basename; + /** + * Returns the full path to this file + * + * @return $this + */ + public function setBasename(?string $basename = null): self + { + $this->basename = $basename; - return $this; - } + return $this; + } - /** - * Returns the full path to this file - * - * @param string $suffix Optional suffix to omit from the base name returned - * - * @return string - */ - public function getBasename($suffix = null): string - { - if ($this->basename) - { - return basename($this->basename, $suffix); - } + /** + * Returns the full path to this file + * + * @param string $suffix Optional suffix to omit from the base name returned + */ + public function getBasename($suffix = null): string + { + if ($this->basename) { + return basename($this->basename, $suffix); + } - return parent::getBasename($suffix); - } + return parent::getBasename($suffix); + } } diff --git a/src/Views/Dropzone/config.php b/src/Views/Dropzone/config.php index a89de09..d9ed566 100644 --- a/src/Views/Dropzone/config.php +++ b/src/Views/Dropzone/config.php @@ -1,25 +1,25 @@