diff --git a/file_example/file_example.module b/file_example/file_example.module index 96197c0..e8da4e1 100644 --- a/file_example/file_example.module +++ b/file_example/file_example.module @@ -21,5 +21,45 @@ */ /** + * Control access to private file downloads and specify HTTP headers. + * + * This hook allows modules to enforce permissions on file downloads whenever + * Drupal is handling file download, as opposed to the web server bypassing + * Drupal and returning the file from a public directory. Modules can also + * provide headers to specify information like the file's name or MIME type. + * + * 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 + * try this at home ;-) Remember: keep your files secure! + * + * @param string $uri + * The URI of the file. + * + * @return mixed + * If the user does not have permission to access the file, return -1. If the + * user has permission, return an array with the appropriate headers. If the + * file is not controlled by the current module, the return value should be + * NULL. + * + * @see file_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"; + $current_user = \Drupal::currentUser(); + $account = $current_user->getAccount(); + if ($account->hasPermission($permission)) { + return [ + 'Content-Type: text/plain', + ]; + } + } +} + +/** * @} End of "defgroup file_example". */ diff --git a/file_example/file_example.permissions.yml b/file_example/file_example.permissions.yml index 340e6e3..4c59383 100644 --- a/file_example/file_example.permissions.yml +++ b/file_example/file_example.permissions.yml @@ -1,2 +1,13 @@ 'use file example': title: Use the examples in the File Example module. + +# +# We use the following permissions in our hook_file_download implementation. +# See file_example.module for details. +# +'read private files': + title: See private files in the demo. +'read temporary files': + title: See temporary files in the demo. +'read session files': + title: See session files in the demo. diff --git a/file_example/file_example.routing.yml b/file_example/file_example.routing.yml index 5f54179..3ec1b29 100644 --- a/file_example/file_example.routing.yml +++ b/file_example/file_example.routing.yml @@ -14,10 +14,14 @@ filed_example.fileapi: requirements: _permission: 'use file example' -file_example.access_session: - path: '/examples/file_example/access_session' +# In order to view files created with our demo stream wrapper class, +# 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. +file_example.files.session: + path: '/example/file_examples/files/{scheme}' defaults: - _controller: '\Drupal\file_example\Controller\FileExampleController::accessSession' - _title: 'Session Accessor' + _controller: '\Drupal\system\FileDownloadController::download' + scheme: session requirements: - _permission: 'use file example' + _access: 'TRUE' diff --git a/file_example/src/Controller/FileExampleController.php b/file_example/src/Controller/FileExampleController.php index fa0efd4..184238b 100644 --- a/file_example/src/Controller/FileExampleController.php +++ b/file_example/src/Controller/FileExampleController.php @@ -8,8 +8,6 @@ namespace Drupal\file_example\Controller; use Drupal\Core\Controller\ControllerBase; -use Drupal\Core\Url; -use Symfony\Component\HttpFoundation\Response; /** * Controller routines for file example routes. @@ -29,29 +27,4 @@ class FileExampleController extends ControllerBase { return $build; } - /** - * Session handler - * - * Parameters are variable, since we are using this to support a simulated file system built on sessions. - * - * @todo Figure out how routing works. Symfony wants routes defined in advance, and won't just give - * you the path the way D7 did. So this is a research topic. I think we'll need to keep it - * simple and use a query string (?path=), since the Drupal router really does NOT want to do this; - * see https://www.drupal.org/node/1827544. - */ - public function accessSession() { - $path_components = func_get_args(); - $session_path = 'session://' . implode('/', $path_components); - $content = file_get_contents($session_path); - if ($content !== FALSE) { - return array( - '#markup' => t('Contents of @path :', - array('@path' => $session_path)) . ' ' . - print_r($content, TRUE), - ); - } - return t('Unable to load contents of: @path', - array('@path' => $session_path)); - } - } diff --git a/file_example/src/Form/FileExampleReadWriteForm.php b/file_example/src/Form/FileExampleReadWriteForm.php index c9c3144..2108b5a 100644 --- a/file_example/src/Form/FileExampleReadWriteForm.php +++ b/file_example/src/Form/FileExampleReadWriteForm.php @@ -13,8 +13,6 @@ use Drupal\Core\Database\Database; use Drupal\Core\Form\FormBase; use Drupal\Core\Url; use Drupal\file\Entity\File; -use Drupal\Component\Utility\Html; -use Drupal\Core\StringTranslation\TranslatableMarkup; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -26,10 +24,9 @@ class FileExampleReadWriteForm extends FormBase { /** * Constructs a new FileExampleReadWriteForm page. - * */ public function __construct() { - //todo: we may need to inject a session related object here. + // todo: we may need to inject a session related object here. } /** @@ -42,11 +39,12 @@ class FileExampleReadWriteForm extends FormBase { */ protected function l($text, Url $url) { try { - $l = parent::l($text, $url); + $new_url = Url::fromUri(file_create_url($url->getUri())); + $l = parent::l($text, $new_url); return $l; } catch (\Exception $e) { - + // We might want to log this. } return ''; } @@ -57,7 +55,6 @@ class FileExampleReadWriteForm extends FormBase { * @todo set up dependency injections for sessions. */ public static function create(ContainerInterface $container) { - //return new static($container->get('plugin.manager.mail')); return new static(); } @@ -190,19 +187,19 @@ class FileExampleReadWriteForm extends FormBase { return $form; } -/** - * Submit handler to write a managed file. - * - * 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. - * In this function we use FILE_EXISTS_RENAME (the default) as the argument, - * which means that if there's an existing file, create a new non-colliding - * filename and use it. - * - 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. - */ + /** + * Submit handler to write a managed file. + * + * 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. + * In this function we use FILE_EXISTS_RENAME (the default) as the argument, + * which means that if there's an existing file, create a new non-colliding + * filename and use it. + * - 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. + */ public function handleManagedFile(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); $data = $form_values['write_contents']; @@ -219,19 +216,21 @@ class FileExampleReadWriteForm extends FormBase { $this->t('Saved managed file: %file to destination %destination (accessible via !url, actual uri=@uri)', array( '%file' => print_r($file_data, TRUE), - '%destination' => $uri, '@uri' => $file_object->getFileUri(), + '%destination' => $uri, + '@uri' => $file_object->getFileUri(), '!url' => $this->l(t('this URL'), $url), ) ) ); } else { - //this Uri is not routable, so we cannot give a link to it. + // This Uri is not routable, so we cannot give a link to it. drupal_set_message( $this->t('Saved managed file: %file to destination %destination (no URL, since this stream type does not support it)', array( '%file' => print_r($file_data, TRUE), - '%destination' => $uri, '@uri' => $file_object->getFileUri(), + '%destination' => $uri, + '@uri' => $file_object->getFileUri(), ) ) ); @@ -251,40 +250,43 @@ class FileExampleReadWriteForm extends FormBase { 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 + // 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 - $uri = file_create_url($file_object); + $url = file_create_url($file_object); } try { - $url = Url::fromUri($uri); - if ($url->isExternal()) { + // 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; } - // if the Uri is unroutable (such as for a temporary file), or if Drupal cannot create - // a link, we will throw here: - $url->toString(); + // $url->toString(); } catch (\Exception $e) { return FALSE; } - return $url; + return FALSE; } -/** - * Submit handler to write an unmanaged file. - * - * 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. - * This example uses FILE_EXISTS_REPLACE for the third argument, meaning - * that if there's an existing file at this location, it should be replaced. - * - 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. - */ + /** + * Submit handler to write an unmanaged file. + * + * 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. + * This example uses FILE_EXISTS_REPLACE for the third argument, meaning + * that if there's an existing file at this location, it should be replaced. + * - 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. + */ public function handleUnmanagedFile(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); $data = $form_values['write_contents']; @@ -293,8 +295,7 @@ class FileExampleReadWriteForm extends FormBase { // With the unmanaged file we just get a filename back. $filename = file_unmanaged_save_data($data, $destination, FILE_EXISTS_REPLACE); if ($filename) { - $wrapper = \Drupal::service('stream_wrapper_manager')->getViaUri($filename); - $url = self::getExternalUrl($wrapper); + $url = self::getExternalUrl($filename); $_SESSION['file_example_default_file'] = $filename; if ($url) { drupal_set_message( @@ -324,17 +325,17 @@ class FileExampleReadWriteForm extends FormBase { } -/** - * Submit handler to write an unmanaged file using plain PHP functions. - * - * 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. - * - 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. - * - drupal_tempnam() generates a temporary filename for use. - */ + /** + * Submit handler to write an unmanaged file using plain PHP functions. + * + * 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. + * - 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. + * - drupal_tempnam() generates a temporary filename for use. + */ public function handleUnmanagedPHP(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); $data = $form_values['write_contents']; @@ -363,8 +364,7 @@ class FileExampleReadWriteForm extends FormBase { return; } } - $wrapper = \Drupal::service('stream_wrapper_manager')->getViaUri($destination); - $url = self::getExternalUrl($wrapper); + $url = self::getExternalUrl($destination); $_SESSION['file_example_default_file'] = $destination; if ($url) { drupal_set_message( @@ -391,26 +391,26 @@ class FileExampleReadWriteForm extends FormBase { } -/** - * Submit handler for reading a stream wrapper. - * - * Drupal now has full support for PHP's stream wrappers, which means that - * instead of the traditional use of all the file functions - * ($fp = fopen("/tmp/some_file.txt");) far more sophisticated and generalized - * (and extensible) things can be opened as if they were files. Drupal itself - * provides the public:// and private:// schemes for handling public and - * private files. PHP provides file:// (the default) and http://, so that a - * URL can be read or written (as in a POST) as if it were a file. In addition, - * new schemes can be provided for custom applications, as will be demonstrated - * below. - * - * Here we take the stream wrapper provided in the form. We grab the - * contents with file_get_contents(). Notice that's it's as simple as that: - * file_get_contents("http://example.com") or - * 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(). - */ + /** + * Submit handler for reading a stream wrapper. + * + * Drupal now has full support for PHP's stream wrappers, which means that + * instead of the traditional use of all the file functions + * ($fp = fopen("/tmp/some_file.txt");) far more sophisticated and generalized + * (and extensible) things can be opened as if they were files. Drupal itself + * provides the public:// and private:// schemes for handling public and + * private files. PHP provides file:// (the default) and http://, so that a + * URL can be read or written (as in a POST) as if it were a file. In addition, + * new schemes can be provided for custom applications, as will be demonstrated + * below. + * + * Here we take the stream wrapper provided in the form. We grab the + * contents with file_get_contents(). Notice that's it's as simple as that: + * file_get_contents("http://example.com") or + * 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(). + */ public function handleFileRead(array &$form, FormStateInterface $form_state) { $form_values = $form_state->getValues(); $uri = $form_values['fileops_file']; @@ -432,7 +432,7 @@ class FileExampleReadWriteForm extends FormBase { $url = self::getExternalUrl($sourcename); $_SESSION['file_example_default_file'] = $sourcename; if ($url) { - //We need to convert the URL to string. Since the URL class throws on non-routables. + // We need to convert the URL to string. Since the URL class throws on non-routables. $url_string = file_create_url($url->getUri()); drupal_set_message( $this->t('The file was read and copied to %filename which is accessible at !url', @@ -482,26 +482,21 @@ class FileExampleReadWriteForm extends FormBase { if (!empty($file_object)) { // While file_delete should return FALSE on failure, // it can currently throw an exception on certain cache states. - $result = FALSE; try { - $result = file_delete($file_object); + // This no longer returns a result code. If things go bad, + // 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; } catch (\Exception $e) { - //we should never get here, but as of 8.0rc1, YES WE CAN! - error_log('should not get here'); - } - if ($result !== TRUE) { drupal_set_message(t('Failed deleting managed file %uri. Result was %result', array( '%uri' => $uri, - '%result' => print_r($result, TRUE), + '%result' => print_r($e->getMessage(), TRUE), ) ), 'error'); } - else { - drupal_set_message(t('Successfully deleted managed file %uri', array('%uri' => $uri))); - $_SESSION['file_example_default_file'] = $uri; - } } // Else use file_unmanaged_delete(). else { @@ -514,7 +509,6 @@ class FileExampleReadWriteForm extends FormBase { $_SESSION['file_example_default_file'] = $uri; } } - } /** @@ -579,11 +573,11 @@ class FileExampleReadWriteForm extends FormBase { /** * Submit handler to test directory existence. * - * This actually just checks to see if the directory is writable + * This actually just checks to see if the directory is writable. * * @param array $form * FormAPI form. - * @param array $form_state + * @param FormStateInterface $form_state * FormAPI form state. */ public function handleDirectoryExists(array &$form, FormStateInterface $form_state) { @@ -605,7 +599,7 @@ class FileExampleReadWriteForm extends FormBase { $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"]')); + dsm($_SESSION['file_example'], $this->t('Entire $_SESSION["file_example"]')); } else { drupal_set_message('
' . print_r($_SESSION['file_example'], TRUE) . ''); @@ -626,31 +620,32 @@ class FileExampleReadWriteForm extends FormBase { * {@inheritdoc} */ public function validateForm(array &$form, FormStateInterface $form_state) { - //we don't use this, but the interface requires us to implement it. + // We don't use this, but the interface requires us to implement it. } /** * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - //we don't use this, but the interface requires us to implement it. + // We don't use this, but the interface requires us to implement it. } /** - * 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 - * - * @todo This should still work. An entity query could be used instead. May be other alternatives. - */ + * 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. + */ private static function getManagedFile($uri) { $fid = Database::getConnection('default')->query( 'SELECT fid FROM {file_managed} WHERE uri = :uri', diff --git a/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php b/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php index 64f7b95..a019c71 100644 --- a/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php +++ b/file_example/src/StreamWrapper/FileExampleSessionStreamWrapper.php @@ -10,10 +10,8 @@ namespace Drupal\file_example\StreamWrapper; use Drupal\Core\StreamWrapper\StreamWrapperInterface; -use Drupal\Core\StreamWrapper; -use Drupal\Core\Url; -use Drupal\Component\Utility\Unicode; use Drupal\Component\Utility\Html; +use Drupal\Core\Routing\UrlGeneratorTrait; /** * Example stream wrapper class to handle session:// streams. @@ -47,6 +45,10 @@ use Drupal\Component\Utility\Html; */ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { + // 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. * @@ -92,6 +94,7 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * Returns the type of stream wrapper. * * @return int + * See StreamWrapperInterface for permissible values. */ public static function getType() { return StreamWrapperInterface::NORMAL; @@ -158,40 +161,6 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { } /** - * Implements getMimeType(). - * - * @todo See if we can remove this; it's not part of the new API. - */ - public static function getMimeType($uri, $mapping = NULL) { - if (!isset($mapping)) { - // The default file map, defined in file.mimetypes.inc is quite big. - // We only load it when necessary. - include_once DRUPAL_ROOT . '/includes/file.mimetypes.inc'; - $mapping = file_mimetype_mapping(); - } - - $extension = ''; - $file_parts = explode('.', basename($uri)); - - // Remove the first part: a full filename should not match an extension. - array_shift($file_parts); - - // Iterate over the file parts, trying to find a match. - // For my.awesome.image.jpeg, we try: - // - jpeg - // - image.jpeg, and - // - awesome.image.jpeg - while ($additional_part = array_pop($file_parts)) { - $extension = Unicode::strtolower($additional_part . ($extension ? '.' . $extension : '')); - if (isset($mapping['extensions'][$extension])) { - return $mapping['mimetypes'][$mapping['extensions'][$extension]]; - } - } - - return 'application/octet-stream'; - } - - /** * Implements getDirectoryPath(). * * In this case there is no directory string, so return an empty string. @@ -207,14 +176,8 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * key via HTTP; normally it would be accessible some other way. */ public function getExternalUrl() { - $options = [ - 'absolute' => TRUE, - 'query' => [ - 'path' => $this->getLocalPath(), - ], - ]; - $url = Url::fromRoute('file_example.access_session', [], $options); - return $url; + $path = str_replace('\\', '/', $this->getTarget()); + return $this->url('file_example.files.session', ['scheme' => 'session', 'file' => $path], ['absolute' => TRUE]); } /** @@ -297,9 +260,9 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { * Return a reference to the correct $_SESSION key. * * @param string $uri - * The uri: session://something + * The uri: session://something. * @param bool $create - * If TRUE, create the key + * If TRUE, create the key. * * @return array|bool * A reference to the array at the end of the key-path, or @@ -311,7 +274,7 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { $fail = FALSE; $path = $this->getLocalPath($uri); $path_components = explode('/', $path); - // Set up a reference to the root session:// 'directory.' + // 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] === '') { @@ -388,7 +351,7 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { } - /** + /** * Change stream options. * * This method is called to set options on the stream. @@ -690,7 +653,7 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { return FALSE; } - // Create the key in $_SESSION; + // 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. @@ -861,4 +824,5 @@ class FileExampleSessionStreamWrapper implements StreamWrapperInterface { unset($this->directoryKeys); return TRUE; } + } diff --git a/file_example/src/Tests/FileExampleTest.php b/file_example/src/Tests/FileExampleTest.php index 59595e4..425d1a7 100644 --- a/file_example/src/Tests/FileExampleTest.php +++ b/file_example/src/Tests/FileExampleTest.php @@ -1,7 +1,7 @@ priviledgedUser = $this->drupalCreateUser(array('use file example')); + $permissions = [ + 'use file example', + 'read private files', + 'read temporary files', + 'read session files', + ]; + $this->priviledgedUser = $this->drupalCreateUser($permissions); $this->drupalLogin($this->priviledgedUser); } @@ -43,7 +50,7 @@ class FileExampleTest extends WebTestBase { * our own implementation that will hopefully be localizable, but will not have this problem. */ protected function t($string, $args = [], $options = []) { - return (string)t($string, $args, $options); + return (string) t($string, $args, $options); } /** @@ -86,7 +93,7 @@ class FileExampleTest extends WebTestBase { $this->assertRaw(t('Directory %dirname exists', array('%dirname' => $dirname)), 'Verify that directory now does exist.'); // Create a file in the directory we created. - $content = $this->randomString(30); + $content = $this->randomMachineName(30); $filename = $dirname . '/' . $this->randomMachineName(30) . '.txt'; // Assert that the file we're about to create does not yet exist. @@ -124,7 +131,11 @@ class FileExampleTest extends WebTestBase { // checking and make sure that the data we put in is what we get out. if (!in_array($scheme, array('private', 'temporary'))) { $this->clickLink(t('this URL')); - $this->assertText($content); + // assertText give sketchy answers when the content is *exactly* the contents of the + // buffer, so let's do something less fragile. + // $this->assertText($content); + $buffer = $this->getTextContent(); + $this->assertEqual($content, $buffer, "File contents matched."); } // Verify that the file exists. @@ -146,6 +157,7 @@ class FileExampleTest extends WebTestBase { $edit = array( 'fileops_file' => $filename, ); + $this->drupalPostForm('examples/file_example/fileapi', $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')); @@ -160,4 +172,5 @@ class FileExampleTest extends WebTestBase { } } } + }