diff --git a/includes/file.inc b/includes/file.inc index fa7f5eb54..0461437af 100644 --- a/includes/file.inc +++ b/includes/file.inc @@ -30,8 +30,10 @@ require_once DRUPAL_ROOT . '/includes/stream_wrappers.inc'; * - filesize: The size of the file in bytes. * - status: A bitmapped field indicating the status of the file. The first 8 * bits are reserved for Drupal core. The least significant bit indicates - * temporary (0) or permanent (1). Temporary files older than - * DRUPAL_MAXIMUM_TEMP_FILE_AGE will be removed during cron runs. + * temporary (0) or permanent (1). Temporary files will be removed during + * cron runs if they are older than the value of the variable + * "file_temporary_maximum_age", and if clean-up is enabled. Permanent + * files will not be removed. * - timestamp: UNIX timestamp for the date the file was added to the database. */ @@ -63,9 +65,9 @@ define('FILE_EXISTS_ERROR', 2); /** * Indicates that the file is permanent and should not be deleted. * - * Temporary files older than DRUPAL_MAXIMUM_TEMP_FILE_AGE will be removed - * during cron runs, but permanent files will not be removed during the file - * garbage collection process. + * Temporary files older than the file_temporary_maximum_age variable value will + * be, if clean-up not disabled, removed during cron runs, but permanent files + * will not be removed during the file garbage collection process. */ define('FILE_STATUS_PERMANENT', 1); diff --git a/modules/file/file.field.inc b/modules/file/file.field.inc index d592381bd..058af4e27 100644 --- a/modules/file/file.field.inc +++ b/modules/file/file.field.inc @@ -362,7 +362,16 @@ function file_field_delete_file($item, $field, $entity_type, $id, $count = 1) { $file_usage = file_usage_list($file); if ($file->status == 0 || !empty($file_usage['file'])) { file_usage_delete($file, 'file', $entity_type, $id, $count); - return file_delete($file); + // Make the file temporary so it will be deleted on cron. + // We need to check file usage again since file_delete + // used to handle that. + $file_usage = file_usage_list($file); + if (empty($file_usage['file'])) { + $file->status = 0; + if (!empty($file->fid)) { + file_save($file); + } + } } // Even if the file is not deleted, return TRUE to indicate the file field diff --git a/modules/file/file.module b/modules/file/file.module index fb5e9b949..17e46d099 100644 --- a/modules/file/file.module +++ b/modules/file/file.module @@ -139,6 +139,20 @@ function file_file_download($uri, $field_type = 'file') { return; } + // Find out if a temporary file is still used in the system. + if ($file->status != FILE_STATUS_PERMANENT) { + $usage = file_usage_list($file); + if (empty($usage) && $file->uid != $user->uid) { + // Deny access to temporary files without usage that are not owned by the + // same user. This prevents the security issue that a private file that + // was protected by field permissions becomes available after its usage + // was removed and before it is actually deleted from the file system. + // Modules that depend on this behavior should make the file permanent + // instead. + return -1; + } + } + // Find out which (if any) fields of this type contain the file. $references = file_get_file_references($file, NULL, FIELD_LOAD_CURRENT, $field_type); diff --git a/modules/file/tests/file.test b/modules/file/tests/file.test index b3a1424dc..db18e4dc5 100644 --- a/modules/file/tests/file.test +++ b/modules/file/tests/file.test @@ -242,6 +242,18 @@ class FileFieldTestCase extends DrupalWebTestCase { return $file; } + + /** + * Asserts that a file's status is set to temporary in the database. + */ + function assertFileIsTemporary($file, $message = NULL) { + // We need to query the database directly because the values on the object + // are cached at this point, so rather than calling file_load, we can just + // query the DB for that one value we need. + $status = db_query("SELECT status FROM {file_managed} WHERE fid = :fid", array(':fid' => $file->fid))->fetchField(); + $message = isset($message) ? $message : format_string('File %file is temporary.', array('%file' => $file->uri)); + $this->assertTrue($status != FILE_STATUS_PERMANENT, $message); + } } /** @@ -1055,13 +1067,11 @@ class FileFieldRevisionTestCase extends FileFieldTestCase { // not be necessary here. The file really is deleted, but stream wrappers // doesn't seem to think so unless we clear the PHP file stat() cache. clearstatcache(); - $this->assertFileNotExists($node_file_r3, 'Second file is now deleted after deleting third revision, since it is no longer being used by any other nodes.'); - $this->assertFileEntryNotExists($node_file_r3, 'Second file entry is now deleted after deleting third revision, since it is no longer being used by any other nodes.'); + $this->assertFileIsTemporary($node_file_r3, 'Second file is now set to temporary after deleting third revision, since it is no longer being used by any other nodes.'); // Delete the entire node and check that the original file is deleted. $this->drupalPost('node/' . $nid . '/delete', array(), t('Delete')); - $this->assertFileNotExists($node_file_r1, 'Original file is deleted after deleting the entire node with two revisions remaining.'); - $this->assertFileEntryNotExists($node_file_r1, 'Original file entry is deleted after deleting the entire node with two revisions remaining.'); + $this->assertFileIsTemporary($node_file_r1, 'Original file is set to temporary after deleting the entire node with two revisions remaining.'); } } @@ -1527,7 +1537,8 @@ class FilePrivateTestCase extends FileFieldTestCase { $this->drupalGet(file_create_url($node_file->uri)); $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); $this->drupalLogOut(); - $this->drupalGet(file_create_url($node_file->uri)); + $file_url = file_create_url($node_file->uri); + $this->drupalGet($file_url); $this->assertResponse(403, 'Confirmed that access is denied for the file without the needed permission.'); // Test with the field that should deny access through field access. @@ -1536,7 +1547,8 @@ class FilePrivateTestCase extends FileFieldTestCase { $node = node_load($nid, NULL, TRUE); $node_file = (object) $node->{$no_access_field_name}[LANGUAGE_NONE][0]; // Ensure the file cannot be downloaded. - $this->drupalGet(file_create_url($node_file->uri)); + $file_url = file_create_url($node_file->uri); + $this->drupalGet($file_url); $this->assertResponse(403, 'Confirmed that access is denied for the file without view field access permission.'); // Attempt to reuse the existing file when creating a new node, and confirm @@ -1549,9 +1561,28 @@ class FilePrivateTestCase extends FileFieldTestCase { $this->assertTrue(!empty($new_node), 'Node was created.'); $this->assertUrl('node/' . $new_node->nid); $this->assertNoRaw($node_file->filename, 'File without view field access permission does not appear after attempting to attach it to a new node.'); - $this->drupalGet(file_create_url($node_file->uri)); + $file_url = file_create_url($node_file->uri); + $this->drupalGet($file_url); $this->assertResponse(403, 'Confirmed that access is denied for the file without view field access permission after attempting to attach it to a new node.'); + // Delete the node. + node_delete($node->nid); + + // Ensure the file can still be downloaded by the owner. + $this->drupalGet($file_url); + $this->assertResponse(200, 'Confirmed that the owner still has access to the temporary file.'); + + // Ensure the file cannot be downloaded by an anonymous user. + $this->drupalLogout(); + $this->drupalGet($file_url); + $this->assertResponse(403, 'Confirmed that access is denied for an anonymous user to the temporary file.'); + + // Ensure the file cannot be downloaded by another user. + $account = $this->drupalCreateUser(); + $this->drupalLogin($account); + $this->drupalGet($file_url); + $this->assertResponse(403, 'Confirmed that access is denied for another user to the temporary file.'); + // As an anonymous user, create a temporary file with no references and // confirm that only the session that uploaded it may view it. $this->drupalLogout(); diff --git a/modules/simpletest/tests/file.test b/modules/simpletest/tests/file.test index 89ecac7ae..77517cecb 100644 --- a/modules/simpletest/tests/file.test +++ b/modules/simpletest/tests/file.test @@ -2363,6 +2363,13 @@ class FileDownloadTest extends FileTestCase { $file = $this->createFile(NULL, $contents, 'private'); $url = file_create_url($file->uri); + // Created private files without usage are by default not accessible for a + // user different from the owner, but createFile always uses UID 1 as the + // owner of the files. Therefore make it permanent to allow access if a + // module allows it. + $file->status = FILE_STATUS_PERMANENT; + file_save($file); + // Set file_test access header to allow the download. file_test_set_return('download', array('x-foo' => 'Bar')); $this->drupalGet($url); diff --git a/modules/system/system.admin.inc b/modules/system/system.admin.inc index cdcc78fb6..c7c6d2424 100644 --- a/modules/system/system.admin.inc +++ b/modules/system/system.admin.inc @@ -1798,6 +1798,17 @@ function system_clear_page_cache_submit($form, &$form_state) { * @see system_settings_form() */ function system_file_system_settings() { + $intervals = array(0, 21600, 43200, 86400, 604800, 2419200, 7776000); + $period = array_combine($intervals, array_map('format_interval', $intervals)); + $period[0] = t('Never'); + $form['file_temporary_maximum_age'] = array( + '#type' => 'select', + '#title' => t('Delete orphaned files after'), + '#default_value' => variable_get('file_temporary_maximum_age', DRUPAL_MAXIMUM_TEMP_FILE_AGE), + '#options' => $period, + '#description' => t('Orphaned files are not referenced from any content but remain in the file system and may appear in administrative listings. Warning: If enabled, orphaned files will be permanently deleted and may not be recoverable.'), + ); + $form['file_public_path'] = array( '#type' => 'textfield', '#title' => t('Public file system path'), diff --git a/modules/system/system.install b/modules/system/system.install index d5e67435d..36da8018b 100644 --- a/modules/system/system.install +++ b/modules/system/system.install @@ -943,7 +943,7 @@ function system_schema() { 'default' => 0, ), 'status' => array( - 'description' => 'A field indicating the status of the file. Two status are defined in core: temporary (0) and permanent (1). Temporary files older than DRUPAL_MAXIMUM_TEMP_FILE_AGE will be removed during a cron run.', + 'description' => 'A field indicating the status of the file. Two status are defined in core: temporary (0) and permanent (1). Temporary files older than file_temporary_maximum_age will be removed during a cron run.', 'type' => 'int', 'not null' => TRUE, 'default' => 0, diff --git a/modules/system/system.module b/modules/system/system.module index 53844d878..f0f9ef174 100644 --- a/modules/system/system.module +++ b/modules/system/system.module @@ -6,7 +6,7 @@ */ /** - * Maximum age of temporary files in seconds. + * Default maximum age of temporary files in seconds. */ define('DRUPAL_MAXIMUM_TEMP_FILE_AGE', 21600); @@ -3057,23 +3057,28 @@ function system_cron() { ->condition('expiration', REQUEST_TIME, '<') ->execute(); - // Remove temporary files that are older than DRUPAL_MAXIMUM_TEMP_FILE_AGE. - // Use separate placeholders for the status to avoid a bug in some versions - // of PHP. See http://drupal.org/node/352956. - $result = db_query('SELECT fid FROM {file_managed} WHERE status <> :permanent AND timestamp < :timestamp', array( - ':permanent' => FILE_STATUS_PERMANENT, - ':timestamp' => REQUEST_TIME - DRUPAL_MAXIMUM_TEMP_FILE_AGE - )); - foreach ($result as $row) { - if ($file = file_load($row->fid)) { - $references = file_usage_list($file); - if (empty($references)) { - if (!file_delete($file)) { - watchdog('file system', 'Could not delete temporary file "%path" during garbage collection', array('%path' => $file->uri), WATCHDOG_ERROR); + $age = variable_get('file_temporary_maximum_age', DRUPAL_MAXIMUM_TEMP_FILE_AGE); + + // Only delete temporary files if older than $age. Note that automatic cleanup + // is disabled if $age set to 0. + if ($age) { + // Use separate placeholders for the status to avoid a bug in some versions + // of PHP. See http://drupal.org/node/352956. + $result = db_query('SELECT fid FROM {file_managed} WHERE status <> :permanent AND timestamp < :timestamp', array( + ':permanent' => FILE_STATUS_PERMANENT, + ':timestamp' => REQUEST_TIME - $age + )); + foreach ($result as $row) { + if ($file = file_load($row->fid)) { + $references = file_usage_list($file); + if (empty($references)) { + if (!file_delete($file)) { + watchdog('file system', 'Could not delete temporary file "%path" during garbage collection', array('%path' => $file->uri), WATCHDOG_ERROR); + } + } + else { + watchdog('file system', 'Did not delete temporary file "%path" during garbage collection, because it is in use by the following modules: %modules.', array('%path' => $file->uri, '%modules' => implode(', ', array_keys($references))), WATCHDOG_INFO); } - } - else { - watchdog('file system', 'Did not delete temporary file "%path" during garbage collection, because it is in use by the following modules: %modules.', array('%path' => $file->uri, '%modules' => implode(', ', array_keys($references))), WATCHDOG_INFO); } } } diff --git a/modules/system/system.test b/modules/system/system.test index 9eaf562b2..a9bc53ea8 100644 --- a/modules/system/system.test +++ b/modules/system/system.test @@ -866,25 +866,24 @@ class CronRunTestCase extends DrupalWebTestCase { } /** - * Ensure that temporary files are removed. + * Create files for all the possible combinations of age and status. * - * Create files for all the possible combinations of age and status. We are - * using UPDATE statements rather than file_save() because it would set the + * We are using UPDATE statements because using the API would set the * timestamp. */ - function testTempFileCleanup() { - // Temporary file that is older than DRUPAL_MAXIMUM_TEMP_FILE_AGE. + function createTempFiles() { + // Temporary file that is old. $temp_old = file_save_data(''); db_update('file_managed') ->fields(array( 'status' => 0, - 'timestamp' => 1, + 'timestamp' => REQUEST_TIME - variable_get('file_temporary_maximum_age', DRUPAL_MAXIMUM_TEMP_FILE_AGE) - 1, )) ->condition('fid', $temp_old->fid) ->execute(); $this->assertTrue(file_exists($temp_old->uri), 'Old temp file was created correctly.'); - // Temporary file that is less than DRUPAL_MAXIMUM_TEMP_FILE_AGE. + // Temporary file that is new. $temp_new = file_save_data(''); db_update('file_managed') ->fields(array('status' => 0)) @@ -892,17 +891,25 @@ class CronRunTestCase extends DrupalWebTestCase { ->execute(); $this->assertTrue(file_exists($temp_new->uri), 'New temp file was created correctly.'); - // Permanent file that is older than DRUPAL_MAXIMUM_TEMP_FILE_AGE. + // Permanent file that is old. $perm_old = file_save_data(''); db_update('file_managed') - ->fields(array('timestamp' => 1)) + ->fields(array('timestamp' => REQUEST_TIME - variable_get('file_temporary_maximum_age', DRUPAL_MAXIMUM_TEMP_FILE_AGE) - 1)) ->condition('fid', $temp_old->fid) ->execute(); $this->assertTrue(file_exists($perm_old->uri), 'Old permanent file was created correctly.'); - // Permanent file that is newer than DRUPAL_MAXIMUM_TEMP_FILE_AGE. + // Permanent file that is new. $perm_new = file_save_data(''); $this->assertTrue(file_exists($perm_new->uri), 'New permanent file was created correctly.'); + return array($temp_old, $temp_new, $perm_old, $perm_new); + } + + /** + * Ensure that temporary files are removed by default. + */ + function testTempFileCleanupDefault() { + list($temp_old, $temp_new, $perm_old, $perm_new) = $this->createTempFiles(); // Run cron and then ensure that only the old, temp file was deleted. $this->cronRun(); @@ -912,6 +919,40 @@ class CronRunTestCase extends DrupalWebTestCase { $this->assertTrue(file_exists($perm_new->uri), 'New permanent file was correctly ignored.'); } + /** + * Ensure that temporary files are kept as configured. + */ + function testTempFileNoCleanup() { + list($temp_old, $temp_new, $perm_old, $perm_new) = $this->createTempFiles(); + + // Set the max age to 0, meaning no temporary files will be deleted. + variable_set('file_temporary_maximum_age', 0); + + // Run cron and then ensure that no file was deleted. + $this->cronRun(); + $this->assertTrue(file_exists($temp_old->uri), 'Old temp file was correctly ignored.'); + $this->assertTrue(file_exists($temp_new->uri), 'New temp file was correctly ignored.'); + $this->assertTrue(file_exists($perm_old->uri), 'Old permanent file was correctly ignored.'); + $this->assertTrue(file_exists($perm_new->uri), 'New permanent file was correctly ignored.'); + } + + /** + * Ensure that temporary files are kept as configured. + */ + function testTempFileCustomCleanup() { + list($temp_old, $temp_new, $perm_old, $perm_new) = $this->createTempFiles(); + + // Set the max age to older than default. + variable_set('file_temporary_maximum_age', DRUPAL_MAXIMUM_TEMP_FILE_AGE + 60); + + // Run cron and then ensure that more files were deleted. + $this->cronRun(); + $this->assertTrue(file_exists($temp_old->uri), 'Old temp file was correctly ignored.'); + $this->assertTrue(file_exists($temp_new->uri), 'New temp file was correctly ignored.'); + $this->assertTrue(file_exists($perm_old->uri), 'Old permanent file was correctly ignored.'); + $this->assertTrue(file_exists($perm_new->uri), 'New permanent file was correctly ignored.'); + } + /** * Make sure exceptions thrown on hook_cron() don't affect other modules. */