diff --git a/file_example/file_example.links.menu.yml b/file_example/file_example.links.menu.yml index cc7f2bc..cce6e57 100644 --- a/file_example/file_example.links.menu.yml +++ b/file_example/file_example.links.menu.yml @@ -1,8 +1,7 @@ -file_example.description: +# +# This link will appear in the "Tools" menu. +# +file_example.fileapi: title: File Example - route_name: file_example.description - -filed_example.fileapi: - title: Use File API to read/write a file parent: file_example.description - route_name: filed_example.fileapi + route_name: file_example.fileapi diff --git a/file_example/file_example.module b/file_example/file_example.module index e8da4e1..23e708e 100644 --- a/file_example/file_example.module +++ b/file_example/file_example.module @@ -10,13 +10,29 @@ * @{ * Examples demonstrating the Drupal File API (and Stream Wrappers). * - * The File Example module is a part of the Examples for Developers Project - * and provides various Drupal File API Examples. You can download and - * experiment with this code at the - * @link http://drupal.org/project/examples Examples for Developers project page. @endlink + * The File Example module is part of the Examples for Developers Project and + * provides a variety of examples for the Developers project page. Some + * concepts we demonstrate with this module: * - * See @link http://drupal.org/node/555118 Drupal File API @endlink for handbook - * documentation on the File API and + * * Creating, moving and deleting files, and reading and writing from them. + * + * * Using files that Drupal can manage via its Entity API ("managed files"), + * and unmanaged files (the usual kind of file programs deal with). + * + * * Creating and setting up directories with the right permissions, and with + * .htaccess files that prevent unwanted accesses. + * + * * Creating new "file systems" that use PHP's stream wrapper features, + * similar to how Core creates its public://, private:// and temporary:// + * files. + * + * * Allowing restricted access to files the way Drupal private files are + * downloaded. + * + * Some links for further information on the File API and related information: + * + * @link http://drupal.org/project/examples Examples for Developers project + * page. @endlink * @link file File summary on api.drupal.org @endlink for the function summary. */ @@ -31,9 +47,15 @@ * For our example module, we want to be able to see the temporary, private, * and session (our test stream wrapper / file scheme). In general, you really * would NEVER give general access to your temporary, and you certainly wouldn't - * do it for your private files. So we demostrate this here, but kids, don't + * do it for your private files. So we demonstrate this here, but kids, don't * try this at home ;-) Remember: keep your files secure! * + * For hook_file_download() to get called at all, your code needs set up your + * routes so that the download link uses FileDownloadController::download() as + * a controller. FileDownloadController::download() enforces access restrictions + * on the files it managed, in part by invoking hook_file_downloads(). See the + * File Example's routing file to see how to do this. + * * @param string $uri * The URI of the file. * @@ -44,9 +66,11 @@ * NULL. * * @see file_download() + * @see hook_file_download() + * @see file_example.routing.yml + * @see \Drupal\system\FileDownloadController::download() */ function file_example_file_download($uri) { - // Check to see if this is a config download. $scheme = file_uri_scheme($uri); if (in_array($scheme, ['private', 'temporary', 'session'])) { $permission = "read $scheme files"; diff --git a/file_example/file_example.permissions.yml b/file_example/file_example.permissions.yml index 4c59383..9ab5c2a 100644 --- a/file_example/file_example.permissions.yml +++ b/file_example/file_example.permissions.yml @@ -6,8 +6,8 @@ # See file_example.module for details. # 'read private files': - title: See private files in the demo. + title: See private files in the File Example module demo. 'read temporary files': - title: See temporary files in the demo. + title: See temporary files in the File Example module demo. 'read session files': - title: See session files in the demo. + title: See session files in the File Example module demo. diff --git a/file_example/file_example.routing.yml b/file_example/file_example.routing.yml index 3ec1b29..ecd85e4 100644 --- a/file_example/file_example.routing.yml +++ b/file_example/file_example.routing.yml @@ -1,16 +1,8 @@ -file_example.description: +file_example.fileapi: path: '/examples/file_example' defaults: - _controller: '\Drupal\file_example\Controller\FileExampleController::description' - _title: 'File Example' - requirements: - _permission: 'access content' - -filed_example.fileapi: - path: '/examples/file_example/fileapi' - defaults: _form: '\Drupal\file_example\Form\FileExampleReadWriteForm' - _title: 'Use File API to read/write a file' + _title: 'File Example: Use the File API to read/write a file' requirements: _permission: 'use file example' @@ -18,8 +10,14 @@ filed_example.fileapi: # we need to use hook_file_download to grant any access. This route # will make sure that we have an external URL for these files, and that # our hook is called. +# +# In our implementation, access to the files is actually managed by +# permissions defined in file_example.permissions.yml. +# +# @see file_example_file_download() +# file_example.files.session: - path: '/example/file_examples/files/{scheme}' + path: '/example/file_example/files/{scheme}' defaults: _controller: '\Drupal\system\FileDownloadController::download' scheme: session diff --git a/file_example/file_example.services.yml b/file_example/file_example.services.yml index 5e16e97..34c5440 100644 --- a/file_example/file_example.services.yml +++ b/file_example/file_example.services.yml @@ -1,10 +1,18 @@ # -# To implement a stream wrapper, we need to register it with the system. We can either do this -# manually by calling up the 'stream_wrapper.manager' service, or, as we do here, have -# the system autoload it by tagging the service. +# As part of our demo, we implement a simple "file system" that lets us read and write +# files out of the $_SESSION. This isn't very practical, but it's a simple way to +# demonstrate what you can do with PHP's stream wrappers. +# +# To get a stream wrapper to work to define a stream wrapper class, we need to register +# that with the system. We can either do this manually by calling up the 'stream_wrapper.manager' +# service, but the better way to do this is to have the system autoload it by tagging the service, +# as we do here. +# +# @see src/StreamWrapper/FileExampleSessionStreamWrapper.php # services: file_example.stream_wrapper: class: Drupal\file_example\StreamWrapper\FileExampleSessionStreamWrapper + arguments: ['@request_stack'] tags: - { name: stream_wrapper, scheme: session } diff --git a/file_example/src/Controller/FileExampleController.php b/file_example/src/Controller/FileExampleController.php deleted file mode 100644 index 184238b..0000000 --- a/file_example/src/Controller/FileExampleController.php +++ /dev/null @@ -1,30 +0,0 @@ - $this->t('The file example module provides a form and code to demonstrate the Drupal 7 file api. Experiment with the form, and then look at the submit handlers in the code to understand the file api.'), - ); - - return $build; - } - -} diff --git a/file_example/src/Form/FileExampleReadWriteForm.php b/file_example/src/Form/FileExampleReadWriteForm.php index 2108b5a..d839982 100644 --- a/file_example/src/Form/FileExampleReadWriteForm.php +++ b/file_example/src/Form/FileExampleReadWriteForm.php @@ -9,11 +9,17 @@ namespace Drupal\file_example\Form; use Drupal\Core\Form\FormStateInterface; use Drupal\file\FileInterface; +use Drupal\Core\State\StateInterface; use Drupal\Core\Database\Database; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Url; use Drupal\file\Entity\File; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\file_example\StreamWrapper\SessionWrapper; +use Symfony\Component\HttpFoundation\RequestStack; + /** * File test form class. @@ -23,10 +29,149 @@ use Symfony\Component\DependencyInjection\ContainerInterface; class FileExampleReadWriteForm extends FormBase { /** + * @var StateInterface + * Interface of the "state" service for site-specific data. + */ + protected $state; + + /** + * @var RequestStack + * Object used to get request data, such as the session. + */ + protected $requestStack; + + /** + * @var FileSystemInterface + * Service for manipulating a file system. + */ + protected $fileSystem; + + /** + * @var ModuleHandlerInterface + * Handler for invoking hooks and other module operations. + */ + protected $moduleHandler; + + /** * Constructs a new FileExampleReadWriteForm page. + * + * @param StateInterface $state + * Storage interface for state data. + */ + public function __construct(StateInterface $state, FileSystemInterface $file_system, ModuleHandlerInterface $module_handler, RequestStack $request_stack) { + $this->state = $state; + $this->fileSystem = $file_system; + $this->moduleHandler = $module_handler; + $this->requestStack = $request_stack; + } + + /** + * {@inheritdoc} + * + * @todo set up dependency injections for sessions. + */ + public static function create(ContainerInterface $container) { + $state = $container->get('state'); + $file_system = $container->get('file_system'); + $module_handler = $container->get('module_handler'); + $request_stack = $container->get('request_stack'); + return new static($state, $file_system, $module_handler, $request_stack); + } + + /** + * Returns a unique string identifying the form. + * + * @return string + * The unique string identifying the form. + */ + public function getFormID() { + return 'file_example_readwrite'; + } + + /** + * Get the default file. + * + * This appears in the first block of the form. + * + * @return string + * The URI of the default file. + */ + protected function getDefaultFile() { + $default_file = $this->state->get('file_example_default_file', 'session://drupal.txt'); + return $default_file; + } + + /** + * Test a SessionWrapper object. + * + * This is used to change relevant attributes of the Session. + * + * @return SessionWrapper + * Wrapper object to manipulate the SESSION storage. + */ + protected function getSessionWrapper() { + return new SessionWrapper($this->requestStack); + } + + /** + * Set the default file. + * + * Set a default URI of the file used for read and write operations. + * + * @param string $uri + * URI to save for future display in the form. + */ + protected function setDefaultFile($uri) { + $this->state->set('file_example_default_file', (string) $uri); + } + + /** + * Get the default directory. + * + * @return string + * The URI of the default directory. + */ + protected function getDefaultDirectory() { + $default_directory = $this->state->get('file_example_default_directory', 'session://directory1'); + return $default_directory; + } + + /** + * Set the default directory. + * + * @param string $uri + * URI to save for later form display. + */ + protected function setDefaultDirectory($uri) { + $this->state->set('file_example_default_directory', (string) $uri); + } + + /** + * Utility function to check for and return a managed file. + * + * In this demonstration code we don't necessarily know if a file is managed + * or not, so often need to check to do the correct behavior. Normal code + * would not have to do this, as it would be working with either managed or + * unmanaged files. + * + * @param string $uri + * The URI of the file, like public://test.txt. + * + * @return FileInterface|bool + * A file object that matches the URI, or FALSE if not a managed file. + * + * @todo This should still work. An entity query could be used instead. May be other alternatives. */ - public function __construct() { - // todo: we may need to inject a session related object here. + private static function getManagedFile($uri) { + $fid = Database::getConnection('default')->query( + 'SELECT fid FROM {file_managed} WHERE uri = :uri', + array(':uri' => $uri) + )->fetchField(); + if (!empty($fid)) { + $file_object = File::load($fid); + return $file_object; + } + return FALSE; } /** @@ -50,38 +195,63 @@ class FileExampleReadWriteForm extends FormBase { } /** - * {@inheritdoc} + * Prepare Url objects to prevent exceptions by the URL generator. * - * @todo set up dependency injections for sessions. - */ - public static function create(ContainerInterface $container) { - return new static(); - } - - /** - * Returns a unique string identifying the form. + * Helper function to get us an external URL if this is legal, and to catch + * the exception Drupal throws if this is not possible. * - * @return string - * The unique string identifying the form. + * In Drupal 8, the URL generator is very sensitive to how you set things + * up, and some functions, in particular LinkGeneratorTrait::l(), will throw + * exceptions if you deviate from what's expected. This function will raise + * the chances your URL will be valid, and not do this. + * + * @param \Drupal\file\Entity\File $file_object|string + * A file entity object. + * + * @return \Drupal\Core\Url + * A Url object that can be displayed as an internal URL. + * + * @see http://drupal.stackexchange.com/questions/177869/how-to-create-a-url-to-an-unmanaged-public-file-in-drupal-8 */ - public function getFormID() { - return 'file_example_readwrite'; + private static function getExternalUrl($file_object) { + if ($file_object instanceof FileInterface) { + $uri = $file_object->getFileUri(); + $url = Url::fromUri($uri); + } + else { + // A little tricky, since file.inc is a little inconsistent, but often this + // is a Uri. + $url = file_create_url($file_object); + } + + try { + // If the Uri is unroutable (such as for a temporary file), or if Drupal cannot create + // a link, we will throw here: + if (is_string($url)) { + $url = Url::fromUri($url); + } + if (!empty($url) and $url->isExternal()) { + return $url; + } + // $url->toString(); + } + catch (\Exception $e) { + return FALSE; + } + return FALSE; } + /** * {@inheritdoc} - * - * @todo Remove direct manipulation of the session. */ public function buildForm(array $form, FormStateInterface $form_state) { - if (empty($_SESSION['file_example_default_file'])) { - $_SESSION['file_example_default_file'] = 'session://drupal.txt'; - } - $default_file = $_SESSION['file_example_default_file']; - if (empty($_SESSION['file_example_default_directory'])) { - $_SESSION['file_example_default_directory'] = 'session://directory1'; - } - $default_directory = $_SESSION['file_example_default_directory']; + $default_file = $this->getDefaultFile(); + $default_directory = $this->getDefaultDirectory(); + + $form['description'] = array( + '#markup' => $this->t('This form demonstrates the Drupal 8 file api. Experiment with the form, and then look at the submit handlers in the code to understand the file api.'), + ); $form['write_file'] = array( '#type' => 'fieldset', @@ -89,7 +259,7 @@ class FileExampleReadWriteForm extends FormBase { ); $form['write_file']['write_contents'] = array( '#type' => 'textfield', - '#title' => $this->t('Enter something you would like to write to a file') . ' ' . date('m'), + '#title' => $this->t('Enter something you would like to write to a file'), '#default_value' => $this->t('Put some text here or just use this text'), ); @@ -113,7 +283,7 @@ class FileExampleReadWriteForm extends FormBase { $form['write_file']['unmanaged_php'] = array( '#type' => 'submit', '#value' => $this->t('Unmanaged using PHP'), - '#submit' => array('::handleUnmanagedPHP'), + '#submit' => array('::handleUnmanagedPhp'), ); $form['fileops'] = array( @@ -190,6 +360,9 @@ class FileExampleReadWriteForm extends FormBase { /** * Submit handler to write a managed file. * + * A "managed file" is a file that Drupal tracks as a file entity. It's the + * standard way Drupal manages files in file fields and elsewhere. + * * The key functions used here are: * - file_save_data(), which takes a buffer and saves it to a named file and * also creates a tracking record in the database and returns a file object. @@ -199,6 +372,11 @@ class FileExampleReadWriteForm extends FormBase { * - file_create_url(), which converts a URI in the form public://junk.txt or * private://something/test.txt into a URL like * http://example.com/sites/default/files/junk.txt. + * * @param array $form + * An associative array containing the structure of the form. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ public function handleManagedFile(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); @@ -209,7 +387,7 @@ class FileExampleReadWriteForm extends FormBase { $file_object = \file_save_data($data, $uri, FILE_EXISTS_RENAME); if (!empty($file_object)) { $url = self::getExternalUrl($file_object); - $_SESSION['file_example_default_file'] = $file_object->getFileUri(); + $this->setDefaultFile($file_object->getFileUri()); $file_data = $file_object->toArray(); if ($url) { drupal_set_message( @@ -244,40 +422,11 @@ class FileExampleReadWriteForm extends FormBase { } /** - * Helper function to get us an external URL if this is legal, and to catch - * the exception Drupal throws if this is not possible. - */ - private static function getExternalUrl($file_object) { - if ($file_object instanceof FileInterface) { - $uri = $file_object->getFileUri(); - $url = Url::fromUri($uri); - } - else { - // A little tricky, since file.inc is a little inconsistent, but often this - // is a Uri. See http://drupal.stackexchange.com/questions/177869/how-to-create-a-url-to-an-unmanaged-public-file-in-drupal-8 - $url = file_create_url($file_object); - } - - try { - // If the Uri is unroutable (such as for a temporary file), or if Drupal cannot create - // a link, we will throw here: - if (is_string($url)) { - $url = Url::fromUri($url); - } - if (!empty($url) and $url->isExternal()) { - return $url; - } - // $url->toString(); - } - catch (\Exception $e) { - return FALSE; - } - return FALSE; - } - - /** * Submit handler to write an unmanaged file. * + * An unmanaged file is a file that Drupal does not track. A standard + * operating system file, in other words. + * * The key functions used here are: * - file_unmanaged_save_data(), which takes a buffer and saves it to a named * file, but does not create any kind of tracking record in the database. @@ -286,6 +435,11 @@ class FileExampleReadWriteForm extends FormBase { * - file_create_url(), which converts a URI in the form public://junk.txt or * private://something/test.txt into a URL like * http://example.com/sites/default/files/junk.txt. + * * @param array $form + * An associative array containing the structure of the form. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ public function handleUnmanagedFile(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); @@ -296,7 +450,7 @@ class FileExampleReadWriteForm extends FormBase { $filename = file_unmanaged_save_data($data, $destination, FILE_EXISTS_REPLACE); if ($filename) { $url = self::getExternalUrl($filename); - $_SESSION['file_example_default_file'] = $filename; + $this->setDefaultFile($filename); if ($url) { drupal_set_message( $this->t('Saved file as %filename (accessible via !url, uri=@uri)', @@ -335,15 +489,20 @@ class FileExampleReadWriteForm extends FormBase { * private://something/test.txt into a URL like * http://example.com/sites/default/files/junk.txt. * - drupal_tempnam() generates a temporary filename for use. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ - public function handleUnmanagedPHP(array &$form, FormStateInterface $form_state) { + public function handleUnmanagedPhp(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); $data = $form_values['write_contents']; $destination = !empty($form_values['destination']) ? $form_values['destination'] : NULL; if (empty($destination)) { // If no destination has been provided, use a generated name. - $destination = \Drupal::service('file_system')->tempnam('public://', 'file'); + $destination = $this->fileSystem->tempnam('public://', 'file'); } // With all traditional PHP functions we can use the stream wrapper notation @@ -365,7 +524,7 @@ class FileExampleReadWriteForm extends FormBase { } } $url = self::getExternalUrl($destination); - $_SESSION['file_example_default_file'] = $destination; + $this->setDefaultFile($destination); if ($url) { drupal_set_message( $this->t('Saved file as %filename (accessible via !url, uri=@uri)', @@ -410,6 +569,11 @@ class FileExampleReadWriteForm extends FormBase { * file_get_contents("public://somefile.txt") just works. Although it's * not necessary, we use file_unmanaged_save_data() to save this file locally * and then find a local URL for it by using file_create_url(). + * * @param array $form + * An associative array containing the structure of the form. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ public function handleFileRead(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); @@ -430,7 +594,7 @@ class FileExampleReadWriteForm extends FormBase { $sourcename = file_unmanaged_save_data($buffer, 'public://' . $filename); if ($sourcename) { $url = self::getExternalUrl($sourcename); - $_SESSION['file_example_default_file'] = $sourcename; + $this->setDefaultFile($sourcename); if ($url) { // We need to convert the URL to string. Since the URL class throws on non-routables. $url_string = file_create_url($url->getUri()); @@ -468,6 +632,11 @@ class FileExampleReadWriteForm extends FormBase { /** * Submit handler to delete a file. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ public function handleFileDelete(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); @@ -487,7 +656,7 @@ class FileExampleReadWriteForm extends FormBase { // it will throw an exception: file_delete($file_object->id()); drupal_set_message(t('Successfully deleted managed file %uri', array('%uri' => $uri))); - $_SESSION['file_example_default_file'] = $uri; + $this->setDefaultFile($uri); } catch (\Exception $e) { drupal_set_message(t('Failed deleting managed file %uri. Result was %result', @@ -506,7 +675,7 @@ class FileExampleReadWriteForm extends FormBase { } else { drupal_set_message(t('Successfully deleted unmanaged file %uri', array('%uri' => $uri))); - $_SESSION['file_example_default_file'] = $uri; + $this->setDefaultFile('file_example_default_file', $uri); } } } @@ -547,7 +716,7 @@ class FileExampleReadWriteForm extends FormBase { else { $result = is_dir($directory); drupal_set_message(t('Directory %directory is ready for use.', array('%directory' => $directory))); - $_SESSION['file_example_default_directory'] = $directory; + $this->setDefaultDirectory($directory); } } @@ -566,7 +735,7 @@ class FileExampleReadWriteForm extends FormBase { } else { drupal_set_message(t('Recursively deleted directory %directory.', array('%directory' => $directory))); - $_SESSION['file_example_default_directory'] = $directory; + $this->setDefaultDirectory($directory); } } @@ -598,21 +767,29 @@ class FileExampleReadWriteForm extends FormBase { public function handleShowSession(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); // If the devel module is installed, use it's nicer message format. - if (\Drupal::moduleHandler()->moduleExists('devel')) { - dsm($_SESSION['file_example'], $this->t('Entire $_SESSION["file_example"]')); + if ($this->moduleHandler->moduleExists('devel')) { + dsm($$this->getStoredData(), $this->t('Entire $_SESSION["file_example"]')); } else { - drupal_set_message('
' . print_r($_SESSION['file_example'], TRUE) . '
'); + drupal_set_message('
' . print_r($this->getStoredData(), TRUE) . '
'); } } /** - * Utility submit function to show the contents of $_SESSION. + * Utility submit function to reset the demo. + * + * Note this does NOT clear any managed file references in Drupal's DB. Perhaps + * we should do this as well. + * + * @param array $form + * FormAPI form. + * @param FormStateInterface $form_state + * FormAPI form state. */ public function handleResetSession(array &$form, FormStateInterface $form_state) { - unset($_SESSION['file_example']); - unset($_SESSION['file_example_default_file']); - unset($_SESSION['file_example_default_directory']); + $this->state->delete('file_example_default_file'); + $this->state->delete('file_example_default_directory'); + $this->clearStoredData(); drupal_set_message('Session reset.'); } @@ -631,31 +808,19 @@ class FileExampleReadWriteForm extends FormBase { } /** - * Utility function to check for and return a managed file. - * - * In this demonstration code we don't necessarily know if a file is managed - * or not, so often need to check to do the correct behavior. Normal code - * would not have to do this, as it would be working with either managed or - * unmanaged files. - * - * @param string $uri - * The URI of the file, like public://test.txt. - * - * @return FileInterface|bool - * A file object that matches the URI, or FALSE if not a managed file. - * - * @todo This should still work. An entity query could be used instead. May be other alternatives. + * Get our stored data for display. */ - private static function getManagedFile($uri) { - $fid = Database::getConnection('default')->query( - 'SELECT fid FROM {file_managed} WHERE uri = :uri', - array(':uri' => $uri) - )->fetchField(); - if (!empty($fid)) { - $file_object = File::load($fid); - return $file_object; - } - return FALSE; + protected function getStoredData() { + $handle = $this->getSessionWrapper(); + return $handle->getPath(''); + } + + /** + * Reset our stored data. + */ + protected function clearStoredData() { + $handle = $this->getSessionWrapper(); + return $handle->cleanUpStore(); } } diff --git a/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php b/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php index a019c71..a24f897 100644 --- a/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php +++ b/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php @@ -9,10 +9,16 @@ namespace Drupal\file_example\StreamWrapper; +// These classes are used to implement a stream wrapper class. use Drupal\Core\StreamWrapper\StreamWrapperInterface; use Drupal\Component\Utility\Html; use Drupal\Core\Routing\UrlGeneratorTrait; +// These classes are used to let us access the Session object. +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + + /** * Example stream wrapper class to handle session:// streams. * @@ -37,25 +43,48 @@ use Drupal\Core\Routing\UrlGeneratorTrait; * scheme they want to. This example adds the session:// scheme, which allows * reading and writing the $_SESSION['file_example'] key as if it were a file. * + * Drupal makes use of this concept to implement custom URI types like + * "private://" and "public://". To implement a stream wrapper, reading + * the implementation of these stream wrappers is a very good way to get + * started. + * + * To implement a stream wrapper in Drupal, you should do the following: + * + * 1. Create a class that implements the StreamWrapperInterface + * (Drupal\Core\StreamWrapper\StreamWrapperInterface). + * + * 2. Register the class with Drupal. The best way to do this is to + * define a service in your MY_MODULE.services.yml file. The + * service needs to be "tagged" with the scheme you want to implement, + * and, as so: + * + * @code + * tags: + * - { name: stream_wrapper, scheme: session } + * @endcode + * See file_example.services.yml for an example. + * + * 3. (Optional) If you want to be able to access your files over the web, + * you need to add a route that handles, and implement hook_file_download(). + * See file_example.routing.yml for an example of this, and file.module + * for the hook implementation. + * * Note that because this implementation uses simple PHP arrays ($_SESSION) * it is limited to string values, so binary files will not work correctly. * Only text files can be used. * * @ingroup file_example */ -class FileExampleSessionStreamWrapper implements StreamWrapperInterface { +class FileExampleSessionStreamWrapper implements StreamWrapperInterface, ContainerInjectionInterface { // We use this trait in order to get nice system-style links // for files stored via our stream wrapper. use UrlGeneratorTrait; /** - * A generic resource handle. - * - * @var resource + * @var RequestStack */ - public $handle = NULL; - + protected $requestStack; /** * Instance URI (stream). @@ -91,6 +120,13 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { protected $streamPointer; /** + * The mode we are currently in. + * + * Possible values are FALSE, 'r', 'w'. + */ + protected $streamMode; + + /** * Returns the type of stream wrapper. * * @return int @@ -103,9 +139,42 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { /** * Constructor method. + * + * Note this cannot take any arguments; PHP's stream wrapper users + * do not know how to supply them. */ public function __construct() { - $_SESSION['file_example']['.isadir.txt'] = TRUE; + // Dependency injection will not work here, since stream wrappers + // are not loaded the normal way: PHP creates them automatically + // when certain file functions are called. This prevents us from + // passing arguments to the constructor, which we'd need to do in + // order to use standard dependency injection as is typically done + // in Drupal 8. + $this->requestStack = \Drupal::service('request_stack'); + $helper = $this->getSessionWrapper(); + $helper->setPath('.isadir.txt', TRUE); + $this->streamMode = FALSE; + } + + /** + * {@inheritdoc} + * + * Since we are putting our data inside of the Session object, we + * need a way to access it. The solution is to use dependency injection + * to get the RequestStack object. + * + * @see https://www.drupal.org/node/2380327 + */ + public static function create(ContainerInterface $container) { + $request_stack = $container->get('request_stack'); + return new static($request_stack); + } + + /** + * Get wrapped session manipulators. + */ + public function getSessionWrapper() { + return new SessionWrapper($this->requestStack); } /** @@ -245,54 +314,30 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { */ public function stream_open($uri, $mode, $options, &$opened_path) { $this->uri = $uri; - // We make $session_content a reference to the appropriate key in the - // $_SESSION variable. So if the local path were - // /example/test.txt it $session_content would now be a - // reference to $_SESSION['file_example']['example']['test.txt']. - $this->sessionContent = &$this->uri_to_session_key($uri); - - // Reset the stream pointer since this is an open. - $this->streamPointer = 0; - return TRUE; - } - - /** - * Return a reference to the correct $_SESSION key. - * - * @param string $uri - * The uri: session://something. - * @param bool $create - * If TRUE, create the key. - * - * @return array|bool - * A reference to the array at the end of the key-path, or - * FALSE if the path doesn't map to a key-path (and $create is FALSE). - */ - protected function &uri_to_session_key($uri, $create = TRUE) { - // Since our uri_to_session_key() method returns a reference, we - // have to set up a failure flag variable. - $fail = FALSE; $path = $this->getLocalPath($uri); - $path_components = explode('/', $path); - // Set up a reference to the root session:// 'directory.'. - $var = &$_SESSION['file_example']; - // Handle case of just session://. - if (count($path_components) == 1 && $path_components[0] === '') { - return $var; - } - // Walk through the path components and create keys in $_SESSION, - // unless we're told not to create them. - foreach ($path_components as $component) { - if ($create || isset($var[$component])) { - $var = &$var[$component]; + // We will support two modes only, 'r' and 'w'. If the key is 'r', + // check to make sure the file is there. + if (stristr($mode, 'r') !== FALSE) { + $helper = $this->getSessionWrapper(); + if (!$helper->checkPath($path)) { + return FALSE; } else { - // This path doesn't exist as keys, either because the - // key doesn't exist, or because we're told not to create it. - return $fail; + $buffer = $helper->getPath($path); + if (!is_string($buffer)) { + return FALSE; + } + $this->sessionContent = $buffer; } + $this->streamMode = 'r'; } - return $var; + else { + $this->sessionContent = ''; + $this->streamMode = 'w'; + } + // Reset the stream pointer since this is an open. + $this->streamPointer = 0; + return TRUE; } /** @@ -514,6 +559,15 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * @see http://php.net/manual/en/streamwrapper.stream-flush.php */ public function stream_flush() { + if ($this->streamMode == 'w') { + // Since we aren't writing directly to the session, we need to send + // the bytes on to the store. + $helper = $this->getSessionWrapper(); + $path = $this->getLocalPath($this->uri); + $helper->setPath($path, $this->sessionContent); + $this->sessionContent = ''; + $this->streamPointer = 0; + } return TRUE; } @@ -572,16 +626,8 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { */ public function unlink($uri) { $path = $this->getLocalPath($uri); - $path_components = preg_split('/\//', $path); - $fail = FALSE; - $unset = '$_SESSION[\'file_example\']'; - foreach ($path_components as $component) { - $unset .= '[\'' . $component . '\']'; - } - // TODO: Is there a better way to delete from an array? - // drupal_array_get_nested_value() doesn't work because it only returns - // a reference; unsetting a reference only unsets the reference. - eval("unset($unset);"); + $helper = $this->getSessionWrapper(); + $helper->clearPath($path); return TRUE; } @@ -599,14 +645,26 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * @see http://php.net/manual/en/streamwrapper.rename.php */ public function rename($from_uri, $to_uri) { - $from_key = &$this->uri_to_session_key($from_uri); - $to_key = &$this->uri_to_session_key($to_uri); - if (is_dir($to_key) || is_file($to_key)) { + // We get the old key contents, write it + // to a new key, erase the old key. + $from_path = $this->getLocalPath($from_uri); + $to_path = $this->getLocalPath($to_uri); + $helper = $this->getSessionWrapper(); + if (!$helper->checkPath($from_path)) { return FALSE; } - $to_key = $from_key; - unset($from_key); - return TRUE; + $from_key = $helper->getPath($from_path); + $path_info = $helper->getParentPath($to_path); + $parent_path = $path_info['dirname']; + $new_file = $path_info['basename']; + // We will only allow writing to a non-existent file + // in an existing directory. + if ($helper->checkPath($parent_path) && !$helper->checkPath($to_path)) { + $helper->setPath($to_path, $from_key); + $helper->clearPath($from_path); + return TRUE; + } + return FALSE; } /** @@ -652,13 +710,10 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { if (is_dir($uri) || is_file($uri)) { return FALSE; } - - // Create the key in $_SESSION;. - $this->uri_to_session_key($uri, TRUE); - - // Place a magic file inside it to differentiate this from an empty file. - $marker_uri = $uri . '/.isadir.txt'; - $this->uri_to_session_key($marker_uri, TRUE); + $path = $this->getLocalPath($uri); + $helper = $this->getSessionWrapper(); + $new_dir = ['isadir.txt' => TRUE]; + $helper->setPath($path, $new_dir); return TRUE; } @@ -677,16 +732,11 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { */ public function rmdir($uri, $options) { $path = $this->getLocalPath($uri); - $path_components = preg_split('/\//', $path); - $fail = FALSE; - $unset = '$_SESSION[\'file_example\']'; - foreach ($path_components as $component) { - $unset .= '[\'' . $component . '\']'; + $helper = $this->getSessionWrapper(); + if (!$helper->checkPath($path) or !is_array($helper->getPath($path))) { + return FALSE; } - // TODO: I really don't like this eval. - debug($unset, 'array element to be unset'); - eval("unset($unset);"); - + $helper->clearPath($path); return TRUE; } @@ -711,14 +761,21 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * @see http://php.net/manual/en/streamwrapper.url-stat.php */ public function url_stat($uri, $flags) { - // Get a reference to the $_SESSION key for this URI. - $key = $this->uri_to_session_key($uri, FALSE); + $path = $this->getLocalPath($uri); + $helper = $this->getSessionWrapper(); + if (!$helper->checkPath($path)) { + return FALSE; + // No file. + } // Default to fail. $return = FALSE; $mode = 0; + $path_info = $helper->getParentPath($path); + $key = $helper->getPath($path); + $key_name = $path_info['basename']; // We will call an array a directory and the root is always an array. - if (is_array($key) && array_key_exists('.isadir.txt', $key)) { + if (is_array($key)) { // S_IFDIR means it's a directory. $mode = 0040000; } @@ -768,8 +825,13 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * @see http://php.net/manual/en/streamwrapper.dir-opendir.php */ public function dir_opendir($uri, $options) { - $var = &$this->uri_to_session_key($uri, FALSE); - if ($var === FALSE || !array_key_exists('.isadir.txt', $var)) { + $path = $this->getLocalPath($uri); + $helper = $this->getSessionWrapper(); + if (!$helper->checkPath($path)) { + return FALSE; + } + $var = $helper->getPath($path); + if (!is_array($var)) { return FALSE; } diff --git a/file_example/src/StreamWrapper/SessionWrapper.php b/file_example/src/StreamWrapper/SessionWrapper.php new file mode 100644 index 0000000..8cbdcc1 --- /dev/null +++ b/file_example/src/StreamWrapper/SessionWrapper.php @@ -0,0 +1,247 @@ +requestStack = $request_stack; + $this->storePath = ''; + } + + + /** + * Get a fresh session object. + * + * @return SessionInterface + * A session object. + */ + protected function getSession() { + return $this->requestStack->getCurrentRequest()->getSession(); + } + + /** + * Get whatever's in the store. + * + * @return array + * An associated array where scalar data represents file, and arrays represent directories. + */ + protected function getStore() { + $session = $this->getSession(); + $store = $session->get(static::SESSION_BASE_ATTRIBUTE, []); + return $store; + } + + /** + * Since we cannot deal with references to the session, write the whole + * store back. + * + * @param array $store. + * The content of the whole session data store, to replace all of the current data. + */ + protected function setStore($store) { + $session = $this->getSession(); + $session->set(static::SESSION_BASE_ATTRIBUTE, $store); + } + + /** + * Turn a path into the arrays we use internally. + * + * @param string $path + * Path into the store. + * @param bool $is_dir + * Path will be used as a container. Otherwise, just a scalar value. + * + * @return array|bool + * Return an array containing the "bottom" and "tip" of a directory + * hierarchy. You will want to save the 'bottom' array, but you may + * need to manipulate an object at the very tip of the hierarchy + * as defined in the path. The tip will be a string if we are scalar + * and an array otherwise. Since we don't want to create new + * sub arrays as a side effect, we return FALSE the intervening path + * does not exist. + */ + public function processPath($path, $is_dir = FALSE) { + // We need to create a reference into the store for the point + // the of the path, so get a copy of the store. + $store = $this->getStore(); + + if (empty($path)) { + return ['store' => &$store, 'tip' => &$store]; + } + $hierarchy = explode('/', $path); + if (empty($hierarchy) or empty($hierarchy[0])) { + return ['store' => &$store, 'tip' => &$store]; + } + $bottom =& $store; + $tip = array_pop($hierarchy); + + foreach ($hierarchy as $dir) { + if (!isset($bottom[$dir])) { + // If the path does not exist, DO NOT create it. + // That is handled by the stream wrapper code. + return FALSE; + } + $new_tip =& $bottom[$dir]; + $bottom =& $new_tip; + } + // If the hierarchy was empty, just point to the object. + $new_tip =& $bottom[$tip]; + $bottom =& $new_tip; + return ['store' => &$store, 'tip' => &$bottom]; + } + + /** + * The equivalent to dirname() and basename() for a path. + * + * @param string $path + * + * @return array + * . + */ + public function getParentPath($path) { + $dirs = explode('/', $path); + $tip = array_pop($dirs); + $parent = implode('/', $dirs); + return ['dirname' => $parent, 'basename' => $tip]; + } + + /** + * Clear a path into our store. + * + * @param string $path + * The path portion of a URI (i.e., without the SCHEME://). + */ + public function clearPath($path) { + $store = $this->getStore(); + if ($this->checkPath($path)) { + $path_info = $this->getParentPath($path); + $store_info = $this->processPath($path_info['dirname']); + if ($store_info === FALSE) { + // The path was not found, nothing to do. + return; + + } + // We want to clear the key at the tip, so... + unset($store_info['tip'][$path_info['basename']]); + // Write back to the store. + $this->setStore($store_info['store']); + } + + } + + /** + * Get a path. + * + * @param string $path + * A URI with the SCHEME:// part removed. + * + * @return mixed + * Return the stored value at this "node" of the store. + */ + public function getPath($path) { + $path_info = $this->getParentPath($path); + $store_info = $this->processPath(($path_info['dirname'])); + $leaf = $path_info['basename']; + if ($store_info === FALSE) { + return NULL; + } + if ($store_info['store'] === $store_info['tip']) { + // We are at the top of the hierarchy; return the store itself. + if (empty($path_info['basename'])) { + return $store_info['store']; + } + if (!isset($store_info['store'][$leaf])) { + return NULL; + } + } + return $store_info['tip'][$leaf]; + } + + /** + * Set a path. + * + * @param string $path + * Path into the store. + * @param string|array $value + * Set a value. + */ + public function setPath($path, $value) { + $path_info = $this->getParentPath($path); + $store_info = $this->processPath(($path_info['dirname'])); + if ($store_info !== FALSE) { + $store_info['tip'][$path_info['basename']] = $value; + } + $this->setStore($store_info['store']); + } + + /** + * Does path exist? + * + * @param string $path + * Path into the store. + */ + public function checkPath($path) { + $path_info = $this->getParentPath($path); + $store_info = $this->processPath($path_info['dirname']); + if (empty($store_info)) { + // Containing directory did not exist. + return FALSE; + } + return isset($store_info['tip'][$path_info['basename']]); + } + + /** + * Set up the store for use. + */ + public function setUpStore() { + // Nothing to do with $_SESSION version. + } + + + /** + * Zero out the store. + */ + public function cleanUpStore() { + $session = $this->getSession(); + $session->remove(static::SESSION_BASE_ATTRIBUTE); + } + +} diff --git a/file_example/src/Tests/FileExampleTest.php b/file_example/src/Tests/FileExampleTest.php index 425d1a7..ee7c55f 100644 --- a/file_example/src/Tests/FileExampleTest.php +++ b/file_example/src/Tests/FileExampleTest.php @@ -27,6 +27,9 @@ class FileExampleTest extends WebTestBase { */ public static $modules = array('file_example', 'file'); + /** + * @var \Drupal\user\Entity\User + */ protected $priviledgedUser; /** @@ -45,6 +48,8 @@ class FileExampleTest extends WebTestBase { } /** + * {@inheritdoc} + * * t() no longer returns a string, but is used heavily in this test in contexts where it * is important that it really return a string, and not TranslatableMarkup. We substitute * our own implementation that will hopefully be localizable, but will not have this problem. @@ -57,7 +62,7 @@ class FileExampleTest extends WebTestBase { * Test the basic File Example UI. * * - Create a directory to work with - * - Foreach scheme create and read files using each of the three methods. + * - For each scheme create and read files using each of the three methods. */ public function testFileExampleBasic() { @@ -83,13 +88,13 @@ class FileExampleTest extends WebTestBase { $edit = array( 'directory_name' => $dirname, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Check to see if directory exists')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Check to see if directory exists')); $this->assertRaw(t('Directory %dirname does not exist', array('%dirname' => $dirname)), 'Verify that directory does not exist.'); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Create directory')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Create directory')); $this->assertRaw(t('Directory %dirname is ready for use', array('%dirname' => $dirname))); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Check to see if directory exists')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Check to see if directory exists')); $this->assertRaw(t('Directory %dirname exists', array('%dirname' => $dirname)), 'Verify that directory now does exist.'); // Create a file in the directory we created. @@ -100,7 +105,7 @@ class FileExampleTest extends WebTestBase { $edit = array( 'fileops_file' => $filename, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Check to see if file exists')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Check to see if file exists')); $this->assertRaw(t('The file %filename does not exist', array('%filename' => $filename)), 'Verify that file does not yet exist.'); debug( @@ -117,7 +122,12 @@ class FileExampleTest extends WebTestBase { 'write_contents' => $content, 'destination' => $filename, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $button); + $options = []; + if (($scheme == 'session') and ($expected_text[$button] == 'Saved managed file')) { + // $options['query'] = []; + // $options['query']['XDEBUG_SESSION_START'] = 'PHPSTORM';. + } + $this->drupalPostForm('examples/file_example', $edit, $button, $options); debug($expected_text[$button], "Button Text"); $this->assertText($expected_text[$button]); @@ -142,7 +152,7 @@ class FileExampleTest extends WebTestBase { $edit = array( 'fileops_file' => $filename, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Check to see if file exists')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Check to see if file exists')); $this->assertRaw(t('The file %filename exists', array('%filename' => $filename)), 'Verify that file now exists.'); // Now read the file that got written above and verify that we can use @@ -150,7 +160,7 @@ class FileExampleTest extends WebTestBase { $edit = array( 'fileops_file' => $output_filename, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Read the file and store it locally')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Read the file and store it locally')); $this->assertText(t('The file was read and copied')); @@ -158,16 +168,16 @@ class FileExampleTest extends WebTestBase { 'fileops_file' => $filename, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Delete file')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Delete file')); $this->assertText(t('Successfully deleted')); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Check to see if file exists')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Check to see if file exists')); $this->assertRaw(t('The file %filename does not exist', array('%filename' => $filename)), 'Verify file has been deleted.'); $edit = array( 'directory_name' => $dirname, ); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Delete directory')); - $this->drupalPostForm('examples/file_example/fileapi', $edit, $this->t('Check to see if directory exists')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Delete directory')); + $this->drupalPostForm('examples/file_example', $edit, $this->t('Check to see if directory exists')); $this->assertRaw(t('Directory %dirname does not exist', array('%dirname' => $dirname)), 'Verify that directory does not exist after deletion.'); } } diff --git a/file_example/tests/src/Kernel/StreamWrapperTest.php b/file_example/tests/src/Kernel/StreamWrapperTest.php new file mode 100644 index 0000000..530a9c0 --- /dev/null +++ b/file_example/tests/src/Kernel/StreamWrapperTest.php @@ -0,0 +1,162 @@ +root)); + parent::setUp(); + // Typically if we need our tested class to get information from the system, + // we use dependency injection (DI) to get that information to the class. But + // stream wrappers are unusual. They are created automatically by PHP itself + // when it calls one of the standard file functions, and for that reason, the + // constructor functions of stream wrappers cannot be passed any arguments, + // which prevents us from using the stardard DI technique we use in Drupal 8. + // The alternative is to create a "global" container that makes our services + // available to the class, which is what we do here. + $container = new ContainerBuilder(); + $request_stack = $this->createSessionMock(); + $container->set('request_stack', $request_stack); + $container->set('file_system', \Drupal::service('file_system')); + $container->set('kernel', \Drupal::service('kernel')); + \Drupal::setContainer($container); + $this->container = $container; + } + + /** + * Test dialtone. + */ + public function testDialTone() { + $have_session_scheme = \Drupal::service('file_system')->validScheme('session'); + $this->assertTrue($have_session_scheme, "System knows about our stream wrapper"); + } + + /** + * Test functions on a URI. + */ + public function testReadWrite() { + $this->resetStore(); + $store = $this->getCurrentStore(); + + $uri = 'session://drupal.txt'; + + $this->assertFalse(file_exists($uri), "File $uri should not exist yet."); + $handle = fopen($uri, 'wb'); + $this->assertNotEmpty($handle, "Handle for $uri should be non-empty."); + $buffer = "Ain't seen nothin' yet!\n"; + $len = strlen($buffer); + + // Original session class gets an error here, + // "...stream_write wrote 10 bytes more data than requested". + // Does not matter for our demo, so repress error reporting here.". + $old = error_reporting(E_ERROR); + $bytes_written = @fwrite($handle, $buffer); + error_reporting($old); + $this->assertNotFalse($bytes_written, "Write to $uri succeeded."); + + $rslt = fclose($handle); + $this->assertNotFalse($rslt, "Closed $uri."); + $this->assertTrue(file_exists($uri), "File $uri should now exist."); + $this->assertFalse(is_dir($uri), "$uri is not a directory."); + $this->assertTrue(is_file($uri), "$uri is a file."); + $size = filesize($uri); + + // The following fails in the original implementation; the file is larger than the data. + // $this->assertEquals($len, $size, "Size of file $uri should match the data written to it.");. + $contents = file_get_contents($uri); + // The example implementation calls HTML::escape() on output. We reverse it + // well enough for our sample data (this code is not I18n safe). + $contents = Html::decodeEntities($contents); + $this->assertEquals($buffer, $contents, "Data for $uri should make the round trip."); + } + + /** + * Directory creation. + */ + public function testDirectories() { + $this->resetStore(); + $dir_uri = 'session://directory1/directory2'; + $sample_file = 'file.txt'; + $content = "Wrote this as a file?\n"; + $dir2 = basename($dir_uri); + $dir1 = dirname($dir_uri); + + $this->assertFalse(file_exists($dir1), "The outer dir $dir1 should not exist yet."); + // We don't care about mode, since we don't support it. + $worked = mkdir($dir1); + $this->assertTrue(is_dir($dir1), "Directory $dir1 was created."); + $first_file_content = "This one is in the first directory."; + $uri = $dir1 . "/" . $sample_file; + $bytes = file_put_contents($uri, $first_file_content); + $this->assertNotFalse($bytes, "Wrote to $uri.\n"); + $this->assertTrue(file_exists($uri), "File $uri actually exists."); + $got_back = file_get_contents($uri); + $got_back = Html::decodeEntities($got_back); + $this->assertSame($first_file_content, $got_back, "Data in subdir made round trip."); + + // Now try down down nested. + $rslt = mkdir($dir_uri); + $this->assertTrue($rslt, "Nested dir got created."); + $file_in_sub = $dir_uri . "/" . $sample_file; + $bytes = file_put_contents($file_in_sub, $content); + $this->assertNotFalse($bytes, "File in nested dirs got written to."); + $got_back = file_get_contents($file_in_sub); + $got_back = Html::decodeEntities($got_back); + $this->assertSame($content, $got_back, "Data in subdir made round trip."); + $worked = unlink($file_in_sub); + $this->assertTrue($worked, "Deleted file in subdir."); + $this->assertFalse(file_exists($file_in_sub), "File in subdir should not exist."); + } + + /** + * Get the contents of the complete array stored in the session. + */ + protected function getCurrentStore() { + $handle = $this->getSessionWrapper(); + return $handle->getPath(''); + } + + /** + * Clear the session storage area. + */ + protected function resetStore() { + $handle = $this->getSessionWrapper(); + $handle->cleanUpStore(); + } + +} diff --git a/file_example/tests/src/MockSessionTrait.php b/file_example/tests/src/MockSessionTrait.php new file mode 100644 index 0000000..1eeb361 --- /dev/null +++ b/file_example/tests/src/MockSessionTrait.php @@ -0,0 +1,101 @@ +sessionStore = []; + $session = $this->prophesize(SessionInterface::class); + $test = $this; + + $session + ->get('file_example', []) + ->will(function($args) use ($test) { + return $test->getSessionStore(); + }); + + $session + ->set('file_example', Argument::any()) + ->will(function($args) use ($test) { + $test->setSessionStore($args[1]); + }); + + $session + ->remove('file_example') + ->will(function($args) use ($test) { + $test->resetSessionStore(); + }); + + $request = $this->prophesize(Request::class); + $request + ->getSession() + ->willReturn($session->reveal()); + + $request_stack = $this->prophesize(RequestStack::class); + $request_stack + ->getCurrentRequest() + ->willReturn($request->reveal()); + + return $this->requestStack = $request_stack->reveal(); + } + + /** + * Get a session wrapper. + */ + public function getSessionWrapper() { + return new SessionWrapper($this->requestStack); + } + + /** + * Helper for mocks. + */ + public function getSessionStore() { + return $this->sessionStore; + } + + /** + * Helper for our mocks. + */ + public function setSessionStore($data) { + $this->sessionStore = $data; + } + + /** + * Helper for our mocks. + */ + public function resetSessionStore() { + $this->sessionStore = []; + } + +} diff --git a/file_example/tests/src/Unit/SessionWrapperTest.php b/file_example/tests/src/Unit/SessionWrapperTest.php new file mode 100644 index 0000000..6f08d76 --- /dev/null +++ b/file_example/tests/src/Unit/SessionWrapperTest.php @@ -0,0 +1,91 @@ +createSessionMock(); + + // Set up the example. + $helper = new SessionWrapper($this->requestStack); + $helper->setUpStore(); + } + + /** + * Run our wrapper through the paces. + */ + public function testWrapper() { + // Check out root. + $helper = new SessionWrapper($this->requestStack); + $root = $helper->getPath(''); + $this->assertTrue(is_array($root), "The root is an array"); + $this->assertTrue(empty($root), "The root is empty."); + + // Add a top level file. + $helper = new SessionWrapper($this->requestStack); + $helper->setPath('drupal.txt', "Stuff"); + $text = $helper->getPath('drupal.txt'); + $this->assertEquals($text, "Stuff", "File at base of hierarchy can be read."); + + // Add a "directory". + $helper = new SessionWrapper($this->requestStack); + $dir = [ + 'file.txt' => 'More stuff', + ]; + $helper->setPath('directory1', $dir); + $fetched_dir = $helper->getPath('directory1'); + $this->assertEquals($fetched_dir['file.txt'], "More stuff", "File inside of directory can be read."); + + // Check file existance. + $helper = new SessionWrapper($this->requestStack); + $this->assertTrue($helper->checkPath('drupal.txt'), "File at root still exists."); + $this->assertFalse($helper->checkPath('file.txt'), "Non-existant file at root does not exist."); + $this->assertTrue($helper->checkPath('directory1'), "Directory at root still exists."); + $this->assertTrue($helper->checkPath('directory1/file.txt'), "File in directory at root still exists."); + + // Two deep. + $helper = new SessionWrapper($this->requestStack); + $helper->setPath('directory1/directory2', []); + $helper->setPath('directory1/directory2/junk.txt', "Store some junk"); + $text = $helper->getPath('directory1/directory2/junk.txt'); + $this->assertEquals($text, "Store some junk", "File inside of nested directory can be read."); + + // Clear references. + $helper = new SessionWrapper($this->requestStack); + $before = $helper->checkPath('directory1/directory2/junk.txt'); + $this->assertTrue($before, "File 2 deep exists."); + $helper->clearPath('directory1/directory2/junk.txt'); + $after = $helper->checkPath('directory1/directory2/junk.txt'); + $this->assertFalse($after, "File 2 deep should be gone."); + + // Clean up test. + $helper = new SessionWrapper($this->requestStack); + $store = $helper->getPath(''); + $this->assertNotEmpty($store, "Before cleanup store is not empty."); + $helper->cleanUpStore(); + $store = $helper->getPath(''); + $this->assertEmpty($store, "After cleanup store is empty."); + + } + +}