diff --git a/feeds.module b/feeds.module index 0e1bda4a1158466882b8d888c6042733c75120fe..33a4a4674decdde0b0c7f2ae9bdd94954b6325cb 100644 --- a/feeds.module +++ b/feeds.module @@ -1178,3 +1178,43 @@ function feeds_get_feed_nid_entity_callback($entity, array $options, $name, $ent } return $feed_nid; } + +/** + * Implements hook_file_download(). + */ +function feeds_file_download($uri) { + $id = db_query("SELECT id FROM {feeds_source} WHERE source = :uri", array(':uri' => $uri))->fetchField(); + + if (!$id) { + // File is not associated with a feed. + return; + } + + // Get the file record based on the URI. If not in the database just return. + $files = file_load_multiple(array(), array('uri' => $uri)); + foreach ($files as $item) { + // Since some database servers sometimes use a case-insensitive comparison + // by default, double check that the filename is an exact match. + if ($item->uri === $uri) { + $file = $item; + break; + } + } + if (!isset($file)) { + return; + } + + // Check if this file belongs to Feeds. + $usage_list = file_usage_list($file); + if (!isset($usage_list['feeds'])) { + return; + } + + if (!feeds_access('import', $id)) { + // User does not have permission to import this feed. + return -1; + } + + // Return file headers. + return file_get_content_headers($file); +} diff --git a/feeds.pages.inc b/feeds.pages.inc index 6f1481edf6d44cd9230e8b3e6a1f11fbe8ab4f5a..f976d9c2028d093b75467a818ee7e73bd4b3c1a9 100644 --- a/feeds.pages.inc +++ b/feeds.pages.inc @@ -341,7 +341,12 @@ function theme_feeds_upload($variables) { $wrapper = file_stream_wrapper_get_instance_by_uri($file->uri); $description .= '<div class="file-info">'; $description .= '<div class="file-name">'; - $description .= l($file->filename, $wrapper->getExternalUrl()); + if ($wrapper) { + $description .= l($file->filename, $wrapper->getExternalUrl()); + } + else { + $description .= t('URI scheme %scheme not available.', array('%scheme' => file_uri_scheme($uri))); + } $description .= '</div>'; $description .= '<div class="file-size">'; $description .= format_size($file->filesize); diff --git a/plugins/FeedsFileFetcher.inc b/plugins/FeedsFileFetcher.inc index 03ef72a003bd7753dfba881be47796b574149187..5e8e116a74a0365ac6748fc639cb0812a87329f6 100644 --- a/plugins/FeedsFileFetcher.inc +++ b/plugins/FeedsFileFetcher.inc @@ -19,7 +19,7 @@ class FeedsFileFetcherResult extends FeedsFetcherResult { } /** - * Overrides parent::getRaw(); + * Overrides parent::getRaw(). */ public function getRaw() { return $this->sanitizeRaw(file_get_contents($this->file_path)); @@ -69,14 +69,14 @@ class FeedsFileFetcher extends FeedsFetcher { } /** - * Return an array of files in a directory. + * Returns an array of files in a directory. * - * @param $dir + * @param string $dir * A stream wreapper URI that is a directory. * - * @return - * An array of stream wrapper URIs pointing to files. The array is empty - * if no files could be found. Never contains directories. + * @return array + * An array of stream wrapper URIs pointing to files. The array is empty if + * no files could be found. Never contains directories. */ protected function listFiles($dir) { $dir = file_stream_wrapper_uri_normalize($dir); @@ -118,7 +118,7 @@ class FeedsFileFetcher extends FeedsFetcher { $form['source'] = array( '#type' => 'textfield', '#title' => t('File'), - '#description' => t('Specify a path to a file or a directory. Path must start with @scheme://', array('@scheme' => file_default_scheme())), + '#description' => t('Specify a path to a file or a directory. Prefix the path with a scheme. Available schemes: @schemes.', array('@schemes' => implode(', ', $this->config['allowed_schemes']))), '#default_value' => empty($source_config['source']) ? '' : $source_config['source'], ); } @@ -126,34 +126,53 @@ class FeedsFileFetcher extends FeedsFetcher { } /** - * Override parent::sourceFormValidate(). + * Overrides parent::sourceFormValidate(). */ public function sourceFormValidate(&$values) { $values['source'] = trim($values['source']); - $feed_dir = 'public://feeds'; - file_prepare_directory($feed_dir, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + if (empty($this->config['direct'])) { - // If there is a file uploaded, save it, otherwise validate input on - // file. - // @todo: Track usage of file, remove file when removing source. - if ($file = file_save_upload('feeds', array('file_validate_extensions' => array(0 => $this->config['allowed_extensions'])), $feed_dir)) { - $values['source'] = $file->uri; - $values['file'] = $file; - } - elseif (empty($values['source'])) { - form_set_error('feeds][source', t('Upload a file first.')); + $feed_dir = $this->config['directory']; + + if (!file_prepare_directory($feed_dir, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) { + if (user_access('administer feeds')) { + $plugin_key = feeds_importer($this->id)->config[$this->pluginType()]['plugin_key']; + $link = url('admin/structure/feeds/' . $this->id . '/settings/' . $plugin_key); + form_set_error('feeds][FeedsFileFetcher][source', t('Upload failed. Please check the upload <a href="@link">settings.</a>', array('@link' => $link))); + } + else { + form_set_error('feeds][FeedsFileFetcher][source', t('Upload failed. Please contact your site administrator.')); + } + watchdog('feeds', 'The upload directory %directory required by a feed could not be created or is not accessible. A newly uploaded file could not be saved in this directory as a consequence, and the upload was canceled.', array('%directory' => $feed_dir)); + } + // Validate and save uploaded file. + elseif ($file = file_save_upload('feeds', array('file_validate_extensions' => array(0 => $this->config['allowed_extensions'])), $feed_dir)) { + $values['source'] = $file->uri; + $values['file'] = $file; + } + elseif (empty($values['source'])) { + form_set_error('feeds][FeedsFileFetcher][source', t('Please upload a file.')); + } + else { + // File present from previous upload. Nothing to validate. + } } - // If a file has not been uploaded and $values['source'] is not empty, make - // sure that this file is within Drupal's files directory as otherwise - // potentially any file that the web server has access to could be exposed. - elseif (strpos($values['source'], file_default_scheme()) !== 0) { - form_set_error('feeds][source', t('File needs to reside within the site\'s file directory, its path needs to start with @scheme://.', array('@scheme' => file_default_scheme()))); + else { + // Check if chosen url scheme is allowed. + $scheme = file_uri_scheme($values['source']); + if (!$scheme || !in_array($scheme, $this->config['allowed_schemes'])) { + form_set_error('feeds][FeedsFileFetcher][source', t("The file needs to reside within the site's files directory, its path needs to start with scheme://. Available schemes: @schemes.", array('@schemes' => implode(', ', $this->config['allowed_schemes'])))); + } + // Check wether the given path exists. + elseif (!file_exists($values['source'])) { + form_set_error('feeds][FeedsFileFetcher][source', t('The specified file or directory does not exist.')); + } } } /** - * Override parent::sourceSave(). + * Overrides parent::sourceSave(). */ public function sourceSave(FeedsSource $source) { $source_config = $source->getConfigFor($this); @@ -176,7 +195,7 @@ class FeedsFileFetcher extends FeedsFetcher { } /** - * Override parent::sourceDelete(). + * Overrides parent::sourceDelete(). */ public function sourceDelete(FeedsSource $source) { $source_config = $source->getConfigFor($this); @@ -186,17 +205,22 @@ class FeedsFileFetcher extends FeedsFetcher { } /** - * Override parent::configDefaults(). + * Overrides parent::configDefaults(). */ public function configDefaults() { + $schemes = $this->getSchemes(); + $scheme = in_array('private', $schemes) ? 'private' : 'public'; + return array( 'allowed_extensions' => 'txt csv tsv xml opml', 'direct' => FALSE, + 'directory' => $scheme . '://feeds', + 'allowed_schemes' => $schemes, ); } /** - * Override parent::configForm(). + * Overrides parent::configForm(). */ public function configForm(&$form_state) { $form = array(); @@ -214,16 +238,112 @@ class FeedsFileFetcher extends FeedsFetcher { are already on the server.'), '#default_value' => $this->config['direct'], ); + $form['directory'] = array( + '#type' => 'textfield', + '#title' => t('Upload directory'), + '#description' => t('Directory where uploaded files get stored. Prefix the path with a scheme. Available schemes: @schemes.', array('@schemes' => implode(', ', $this->getSchemes()))), + '#default_value' => $this->config['directory'], + '#states' => array( + 'visible' => array(':input[name="direct"]' => array('checked' => FALSE)), + 'required' => array(':input[name="direct"]' => array('checked' => FALSE)), + ), + ); + if ($options = $this->getSchemeOptions()) { + $form['allowed_schemes'] = array( + '#type' => 'checkboxes', + '#title' => t('Allowed schemes'), + '#default_value' => $this->config['allowed_schemes'], + '#options' => $options, + '#description' => t('Select the schemes you want to allow for direct upload.'), + '#states' => array( + 'visible' => array(':input[name="direct"]' => array('checked' => TRUE)), + ), + ); + } + return $form; } /** - * Helper. Deletes a file. + * Overrides parent::configFormValidate(). + * + * Ensure that the chosen directory is accessible. + */ + public function configFormValidate(&$values) { + + $values['directory'] = trim($values['directory']); + $values['allowed_schemes'] = array_filter($values['allowed_schemes']); + + if (!$values['direct']) { + // Ensure that the upload directory field is not empty when not in + // direct-mode. + if (!$values['directory']) { + form_set_error('directory', t('Please specify an upload directory.')); + // Do not continue validating the directory if none was specified. + return; + } + + // Validate the URI scheme of the upload directory. + $scheme = file_uri_scheme($values['directory']); + if (!$scheme || !in_array($scheme, $this->getSchemes())) { + form_set_error('directory', t('Please enter a valid scheme into the directory location.')); + + // Return here so that attempts to create the directory below don't + // throw warnings. + return; + } + + // Ensure that the upload directory exists. + if (!file_prepare_directory($values['directory'], FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) { + form_set_error('directory', t('The chosen directory does not exist and attempts to create it failed.')); + } + } + } + + /** + * Deletes a file. + * + * @param int $fid + * The file id. + * @param int $feed_nid + * The feed node's id, or 0 if a standalone feed. + * + * @return bool|array + * TRUE for success, FALSE in the event of an error, or an array if the file + * is being used by any modules. + * + * @see file_delete() */ protected function deleteFile($fid, $feed_nid) { if ($file = file_load($fid)) { file_usage_delete($file, 'feeds', get_class($this), $feed_nid); - file_delete($file); + return file_delete($file); } + return FALSE; } + + /** + * Returns available schemes. + * + * @return array + * The available schemes. + */ + protected function getSchemes() { + return array_keys(file_get_stream_wrappers(STREAM_WRAPPERS_WRITE_VISIBLE)); + } + + /** + * Returns available scheme options for use in checkboxes or select list. + * + * @return array + * The available scheme array keyed scheme => description + */ + protected function getSchemeOptions() { + $options = array(); + foreach (file_get_stream_wrappers(STREAM_WRAPPERS_WRITE_VISIBLE) as $scheme => $info) { + $options[$scheme] = check_plain($scheme . ': ' . $info['description']); + } + return $options; + } + } diff --git a/tests/feeds_fetcher_file.test b/tests/feeds_fetcher_file.test index 18e97890d38607ac2b1bb81286b0faf213549350..65736031dbec5a36c34fe278b00bcb5e29e07060 100644 --- a/tests/feeds_fetcher_file.test +++ b/tests/feeds_fetcher_file.test @@ -17,11 +17,10 @@ class FeedsFileFetcherTestCase extends FeedsWebTestCase { ); } - /** * Test scheduling on cron. */ - public function test() { + public function testPublicFiles() { // Set up an importer. $this->createImporterConfiguration('Node import', 'node'); // Set and configure plugins and mappings. @@ -37,15 +36,19 @@ class FeedsFileFetcherTestCase extends FeedsWebTestCase { $this->addMappings('node', $mappings); // Straight up upload is covered in other tests, focus on direct mode // and file batching here. - $this->setSettings('node', 'FeedsFileFetcher', array('direct' => TRUE)); + $settings = array( + 'direct' => TRUE, + 'directory' => 'public://feeds', + ); + $this->setSettings('node', 'FeedsFileFetcher', $settings); // Verify that invalid paths are not accepted. - foreach (array('private://', '/tmp/') as $path) { + foreach (array('/tmp/') as $path) { $edit = array( 'feeds[FeedsFileFetcher][source]' => $path, ); $this->drupalPost('import/node', $edit, t('Import')); - $this->assertText("File needs to reside within the site's file directory, its path needs to start with public://."); + $this->assertText("The file needs to reside within the site's files directory, its path needs to start with scheme://. Available schemes:"); $count = db_query("SELECT COUNT(*) FROM {feeds_source} WHERE feed_nid = 0")->fetchField(); $this->assertEqual($count, 0); } @@ -64,4 +67,48 @@ class FeedsFileFetcherTestCase extends FeedsWebTestCase { $this->drupalPost('import/node', $edit, t('Import')); $this->assertText('Created 18 nodes'); } + + /** + * Test uploading private files. + */ + public function testPrivateFiles() { + // Set up an importer. + $this->createImporterConfiguration('Node import', 'node'); + // Set and configure plugins and mappings. + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/node/settings', $edit, 'Save'); + $this->setPlugin('node', 'FeedsFileFetcher'); + $this->setPlugin('node', 'FeedsCSVParser'); + $mappings = array( + '0' => array( + 'source' => 'title', + 'target' => 'title', + ), + ); + $this->addMappings('node', $mappings); + // Straight up upload is covered in other tests, focus on direct mode + // and file batching here. + $settings = array( + 'direct' => TRUE, + 'directory' => 'private://feeds', + ); + $this->setSettings('node', 'FeedsFileFetcher', $settings); + + // Verify batching through directories. + // Copy directory of files. + $dir = 'private://batchtest'; + $this->copyDir($this->absolutePath() . '/tests/feeds/batch', $dir); + + // Ingest directory of files. Set limit to 5 to force processor to batch, + // too. + variable_set('feeds_process_limit', 5); + $edit = array( + 'feeds[FeedsFileFetcher][source]' => $dir, + ); + $this->drupalPost('import/node', $edit, t('Import')); + $this->assertText('Created 18 nodes'); + } + }