diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 34e45a3bcc0..784fd368f99 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +Drupal 7.90, 2022-06-01 +----------------------- +- Improved support for PHP 8.1 +- Improved support for PostgreSQL +- Various bug fixes, optimizations and improvements + Drupal 7.89, 2022-03-02 ----------------------- - Bug fixes for PHP 8.1 diff --git a/includes/bootstrap.inc b/includes/bootstrap.inc index 74204b3def2..29a5fffa884 100644 --- a/includes/bootstrap.inc +++ b/includes/bootstrap.inc @@ -8,7 +8,7 @@ /** * The current system version. */ -define('VERSION', '7.89'); +define('VERSION', '7.90'); /** * Core API compatibility. @@ -1627,7 +1627,7 @@ function drupal_page_header() { */ function drupal_serve_page_from_cache(stdClass $cache) { // Negotiate whether to use compression. - $page_compression = !empty($cache->data['page_compressed']); + $page_compression = !empty($cache->data['page_compressed']) && !empty($cache->data['body']); $return_compressed = $page_compression && isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE; // Get headers set in hook_boot(). Keys are lower-case. @@ -1958,7 +1958,7 @@ function check_plain($text) { * TRUE if the text is valid UTF-8, FALSE if not. */ function drupal_validate_utf8($text) { - if (strlen($text) == 0) { + if (strlen((string) $text) == 0) { return TRUE; } // With the PCRE_UTF8 modifier 'u', preg_match() fails silently on strings diff --git a/includes/common.inc b/includes/common.inc index 766ceff8cdd..904a77ad0e3 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -1500,7 +1500,7 @@ function filter_xss($string, $allowed_tags = array('a', 'em', 'strong', 'cite', // Store the text format. _filter_xss_split($allowed_tags, TRUE); // Remove NULL characters (ignored by some browsers). - $string = str_replace(chr(0), '', $string); + $string = str_replace(chr(0), '', (string) $string); // Remove Netscape 4 JS entities. $string = preg_replace('%&\s*\{[^}]*(\}\s*;?|$)%', '', $string); @@ -2696,6 +2696,7 @@ function drupal_deliver_html_page($page_callback_result) { if ($frame_options && is_null(drupal_get_http_header('X-Frame-Options'))) { drupal_add_http_header('X-Frame-Options', $frame_options); } + drupal_add_http_header('X-Content-Type-Options', 'nosniff'); if (variable_get('block_interest_cohort', TRUE)) { $permissions_policy = drupal_get_http_header('Permissions-Policy'); @@ -8051,8 +8052,14 @@ function entity_extract_ids($entity_type, $entity) { $info = entity_get_info($entity_type); // Objects being created might not have id/vid yet. - $id = isset($entity->{$info['entity keys']['id']}) ? $entity->{$info['entity keys']['id']} : NULL; - $vid = ($info['entity keys']['revision'] && isset($entity->{$info['entity keys']['revision']})) ? $entity->{$info['entity keys']['revision']} : NULL; + if (!empty($info)) { + $id = isset($entity->{$info['entity keys']['id']}) ? $entity->{$info['entity keys']['id']} : NULL; + $vid = ($info['entity keys']['revision'] && isset($entity->{$info['entity keys']['revision']})) ? $entity->{$info['entity keys']['revision']} : NULL; + } + else { + $id = NULL; + $vid = NULL; + } if (!empty($info['entity keys']['bundle'])) { // Explicitly fail for malformed entities missing the bundle property. diff --git a/includes/database/pgsql/schema.inc b/includes/database/pgsql/schema.inc index db8266f8cbf..8d1b388c11a 100644 --- a/includes/database/pgsql/schema.inc +++ b/includes/database/pgsql/schema.inc @@ -12,6 +12,13 @@ class DatabaseSchema_pgsql extends DatabaseSchema { + /** + * PostgreSQL's temporary namespace name. + * + * @var string + */ + protected $tempNamespaceName; + /** * A cache of information about blob columns and sequences of tables. * @@ -97,23 +104,47 @@ class DatabaseSchema_pgsql extends DatabaseSchema { public function queryTableInformation($table) { // Generate a key to reference this table's information on. $key = $this->connection->prefixTables('{' . $table . '}'); - if (!strpos($key, '.')) { + + // Take into account that temporary tables are stored in a different schema. + // \DatabaseConnection::generateTemporaryTableName() sets 'db_temporary_' + // prefix to all temporary tables. + if (strpos($key, '.') === FALSE && strpos($table, 'db_temporary_') === FALSE) { $key = 'public.' . $key; } + else { + $key = $this->getTempNamespaceName() . '.' . $key; + } if (!isset($this->tableInformation[$key])) { - // Split the key into schema and table for querying. - list($schema, $table_name) = explode('.', $key); $table_information = (object) array( 'blob_fields' => array(), 'sequences' => array(), ); - // Don't use {} around information_schema.columns table. - $result = $this->connection->query("SELECT column_name, data_type, column_default FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table AND (data_type = 'bytea' OR (numeric_precision IS NOT NULL AND column_default LIKE :default))", array( - ':schema' => $schema, - ':table' => $table_name, - ':default' => '%nextval%', + + // The bytea columns and sequences for a table can be found in + // pg_attribute, which is significantly faster than querying the + // information_schema. The data type of a field can be found by lookup + // of the attribute ID, and the default value must be extracted from the + // node tree for the attribute definition instead of the historical + // human-readable column, adsrc. + $sql = <<<'EOD' +SELECT pg_attribute.attname AS column_name, format_type(pg_attribute.atttypid, pg_attribute.atttypmod) AS data_type, pg_get_expr(pg_attrdef.adbin, pg_attribute.attrelid) AS column_default +FROM pg_attribute +LEFT JOIN pg_attrdef ON pg_attrdef.adrelid = pg_attribute.attrelid AND pg_attrdef.adnum = pg_attribute.attnum +WHERE pg_attribute.attnum > 0 +AND NOT pg_attribute.attisdropped +AND pg_attribute.attrelid = :key::regclass +AND (format_type(pg_attribute.atttypid, pg_attribute.atttypmod) = 'bytea' +OR pg_get_expr(pg_attrdef.adbin, pg_attribute.attrelid) LIKE 'nextval%') +EOD; + $result = $this->connection->query($sql, array( + ':key' => $key, )); + + if (empty($result)) { + return $table_information; + } + foreach ($result as $column) { if ($column->data_type == 'bytea') { $table_information->blob_fields[$column->column_name] = TRUE; @@ -131,6 +162,19 @@ class DatabaseSchema_pgsql extends DatabaseSchema { return $this->tableInformation[$key]; } + /** + * Gets PostgreSQL's temporary namespace name. + * + * @return string + * PostgreSQL's temporary namespace anme. + */ + protected function getTempNamespaceName() { + if (!isset($this->tempNamespaceName)) { + $this->tempNamespaceName = $this->connection->query('SELECT nspname FROM pg_namespace WHERE oid = pg_my_temp_schema()')->fetchField(); + } + return $this->tempNamespaceName; + } + /** * Fetch the list of CHECK constraints used on a field. * @@ -370,6 +414,68 @@ class DatabaseSchema_pgsql extends DatabaseSchema { return implode(', ', $return); } + /** + * {@inheritdoc} + */ + public function tableExists($table) { + // In PostgreSQL "unquoted names are always folded to lower case." + // @see DatabaseSchema_pgsql::buildTableNameCondition(). + $prefixInfo = $this->getPrefixInfo(strtolower($table), TRUE); + + return (bool) $this->connection->query("SELECT 1 FROM pg_tables WHERE schemaname = :schema AND tablename = :table", array(':schema' => $prefixInfo['schema'], ':table' => $prefixInfo['table']))->fetchField(); + } + + /** + * {@inheritdoc} + */ + public function findTables($table_expression) { + $individually_prefixed_tables = $this->connection->getUnprefixedTablesMap(); + $default_prefix = $this->connection->tablePrefix(); + $default_prefix_length = strlen($default_prefix); + $tables = array(); + + // Load all the tables up front in order to take into account per-table + // prefixes. The actual matching is done at the bottom of the method. + $results = $this->connection->query("SELECT tablename FROM pg_tables WHERE schemaname = :schema", array(':schema' => $this->defaultSchema)); + foreach ($results as $table) { + // Take into account tables that have an individual prefix. + if (isset($individually_prefixed_tables[$table->tablename])) { + $prefix_length = strlen($this->connection->tablePrefix($individually_prefixed_tables[$table->tablename])); + } + elseif ($default_prefix && substr($table->tablename, 0, $default_prefix_length) !== $default_prefix) { + // This table name does not start the default prefix, which means that + // it is not managed by Drupal so it should be excluded from the result. + continue; + } + else { + $prefix_length = $default_prefix_length; + } + + // Remove the prefix from the returned tables. + $unprefixed_table_name = substr($table->tablename, $prefix_length); + + // The pattern can match a table which is the same as the prefix. That + // will become an empty string when we remove the prefix, which will + // probably surprise the caller, besides not being a prefixed table. So + // remove it. + if (!empty($unprefixed_table_name)) { + $tables[$unprefixed_table_name] = $unprefixed_table_name; + } + } + + // Need to use strtolower on the table name as it was used previously by + // DatabaseSchema_pgsql::buildTableNameCondition(). + // @see https://www.drupal.org/project/drupal/issues/3262341 + $table_expression = strtolower($table_expression); + + // Convert the table expression from its SQL LIKE syntax to a regular + // expression and escape the delimiter that will be used for matching. + $table_expression = str_replace(array('%', '_'), array('.*?', '.'), preg_quote($table_expression, '/')); + $tables = preg_grep('/^' . $table_expression . '$/i', $tables); + + return $tables; + } + function renameTable($table, $new_name) { if (!$this->tableExists($table)) { throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot rename @table to @table_new: table @table doesn't exist.", array('@table' => $table, '@table_new' => $new_name))); @@ -493,6 +599,17 @@ class DatabaseSchema_pgsql extends DatabaseSchema { $this->connection->query('ALTER TABLE {' . $table . '} ALTER COLUMN "' . $field . '" DROP DEFAULT'); } + /** + * {@inheritdoc} + */ + public function fieldExists($table, $column) { + // In PostgreSQL "unquoted names are always folded to lower case." + // @see DatabaseSchema_pgsql::buildTableNameCondition(). + $prefixInfo = $this->getPrefixInfo(strtolower($table)); + + return (bool) $this->connection->query("SELECT 1 FROM pg_attribute WHERE attrelid = :key::regclass AND attname = :column AND NOT attisdropped AND attnum > 0", array(':key' => $prefixInfo['schema'] . '.' . $prefixInfo['table'], ':column' => $column))->fetchField(); + } + public function indexExists($table, $name) { // Details https://www.postgresql.org/docs/10/view-pg-indexes.html $index_name = $this->ensureIdentifiersLength($table, $name, 'idx'); diff --git a/includes/entity.inc b/includes/entity.inc index e80ce3b89fd..2500e383cc7 100644 --- a/includes/entity.inc +++ b/includes/entity.inc @@ -254,7 +254,10 @@ class DrupalDefaultEntityController implements DrupalEntityControllerInterface { * Callback for array_filter that removes non-integer IDs. */ protected function filterId($id) { - return is_numeric($id) && $id == (int) $id; + // ctype_digit() is used here instead of a strict comparison as sometimes + // the id is passed as a string containing '0' which may represent a bug + // elsewhere but would fail with a strict comparison. + return is_numeric($id) && $id == (int) $id && ctype_digit((string) $id); } /** diff --git a/includes/file.inc b/includes/file.inc index 741fd8380ce..c55a0577321 100644 --- a/includes/file.inc +++ b/includes/file.inc @@ -539,6 +539,10 @@ SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006 php_flag engine off +# From PHP 8 there is no number in the module name. + + php_flag engine off + EOF; if ($private) { diff --git a/includes/locale.inc b/includes/locale.inc index 11f1413eec6..b0287faedd1 100644 --- a/includes/locale.inc +++ b/includes/locale.inc @@ -1603,7 +1603,7 @@ function _locale_parse_js_file($filepath) { if ($source) { // We already have this source string and now have to add the location // to the location column, if this file is not yet present in there. - $locations = preg_split('~\s*;\s*~', $source->location); + $locations = preg_split('~\s*;\s*~', (string) $source->location); if (!in_array($filepath, $locations)) { $locations[] = $filepath; diff --git a/modules/comment/comment.module b/modules/comment/comment.module index 23fb2f5d3bc..786be42de94 100644 --- a/modules/comment/comment.module +++ b/modules/comment/comment.module @@ -1918,7 +1918,6 @@ function comment_form($form, &$form_state, $comment) { if ($is_admin) { $author = (!$comment->uid && $comment->name ? $comment->name : $comment->registered_name); $status = (isset($comment->status) ? $comment->status : COMMENT_NOT_PUBLISHED); - $date = (!empty($comment->date) ? $comment->date : format_date($comment->created, 'custom', 'Y-m-d H:i O')); } else { if ($user->uid) { @@ -1928,7 +1927,11 @@ function comment_form($form, &$form_state, $comment) { $author = ($comment->name ? $comment->name : ''); } $status = (user_access('skip comment approval') ? COMMENT_PUBLISHED : COMMENT_NOT_PUBLISHED); - $date = ''; + } + + $date = ''; + if ($comment->cid) { + $date = !empty($comment->date) ? $comment->date : format_date($comment->created, 'custom', 'Y-m-d H:i:s O'); } // Add the author name field depending on the current user. @@ -2176,7 +2179,7 @@ function comment_submit($comment) { if (empty($comment->date)) { $comment->date = 'now'; } - $comment->created = strtotime($comment->date); + $comment->created = strtotime($comment->date, REQUEST_TIME); $comment->changed = REQUEST_TIME; // If the comment was posted by a registered user, assign the author's ID. diff --git a/modules/comment/comment.test b/modules/comment/comment.test index b70fa26c387..f87560bdf94 100644 --- a/modules/comment/comment.test +++ b/modules/comment/comment.test @@ -1003,7 +1003,7 @@ class CommentPreviewTest extends CommentHelperCase { */ function testCommentEditPreviewSave() { $langcode = LANGUAGE_NONE; - $web_user = $this->drupalCreateUser(array('access comments', 'post comments', 'skip comment approval')); + $web_user = $this->drupalCreateUser(array('access comments', 'post comments', 'skip comment approval', 'edit own comments')); $this->drupalLogin($this->admin_user); $this->setCommentPreview(DRUPAL_OPTIONAL); $this->setCommentForm(TRUE); @@ -1017,7 +1017,7 @@ class CommentPreviewTest extends CommentHelperCase { $edit['date'] = '2008-03-02 17:23 +0300'; $raw_date = strtotime($edit['date']); $expected_text_date = format_date($raw_date); - $expected_form_date = format_date($raw_date, 'custom', 'Y-m-d H:i O'); + $expected_form_date = format_date($raw_date, 'custom', 'Y-m-d H:i:s O'); $comment = $this->postComment($this->node, $edit['subject'], $edit['comment_body[' . $langcode . '][0][value]'], TRUE); $this->drupalPost('comment/' . $comment->id . '/edit', $edit, t('Preview')); @@ -1059,7 +1059,16 @@ class CommentPreviewTest extends CommentHelperCase { $this->assertEqual($comment_loaded->comment_body[$langcode][0]['value'], $edit['comment_body[' . $langcode . '][0][value]'], 'Comment body loaded.'); $this->assertEqual($comment_loaded->name, $edit['name'], 'Name loaded.'); $this->assertEqual($comment_loaded->created, $raw_date, 'Date loaded.'); + $this->drupalLogout(); + // Check that the date and time of the comment are correct when edited by + // non-admin users. + $user_edit = array(); + $expected_created_time = $comment_loaded->created; + $this->drupalLogin($web_user); + $this->drupalPost('comment/' . $comment->id . '/edit', $user_edit, t('Save')); + $comment_loaded = comment_load($comment->id, TRUE); + $this->assertEqual($comment_loaded->created, $expected_created_time, 'Expected date and time for comment edited.'); } } diff --git a/modules/dblog/dblog.admin.inc b/modules/dblog/dblog.admin.inc index f8a00c26bb0..df9f6a87199 100644 --- a/modules/dblog/dblog.admin.inc +++ b/modules/dblog/dblog.admin.inc @@ -286,13 +286,19 @@ function theme_dblog_message($variables) { $event = $variables['event']; // Check for required properties. if (isset($event->message) && isset($event->variables)) { + $event_variables = @unserialize($event->variables); // Messages without variables or user specified text. - if ($event->variables === 'N;') { + if ($event_variables === NULL) { $output = $event->message; } + elseif (!is_array($event_variables)) { + $output = t('Log data is corrupted and cannot be unserialized: @message', array( + '@message' => $event->message, + )); + } // Message to translate with injected variables. else { - $output = t($event->message, unserialize($event->variables)); + $output = t($event->message, $event_variables); } // If the output is expected to be a link, strip all the tags and // special characters by using filter_xss() without any allowed tags. diff --git a/modules/dblog/dblog.test b/modules/dblog/dblog.test index b0a58ba4543..9c266656fd8 100644 --- a/modules/dblog/dblog.test +++ b/modules/dblog/dblog.test @@ -58,12 +58,42 @@ class DBLogTestCase extends DrupalWebTestCase { $this->verifyCron($row_limit); $this->verifyEvents(); $this->verifyReports(); + $this->testDBLogCorrupted(); // Login the regular user. $this->drupalLogin($this->any_user); $this->verifyReports(403); } + /** + * Tests corrupted log entries can still display available data. + */ + private function testDBLogCorrupted() { + global $base_root; + + // Prepare the fields to be logged + $log = array( + 'type' => 'custom', + 'message' => 'Log entry added to test the unserialize failure.', + 'variables' => 'BAD SERIALIZED DATA', + 'severity' => WATCHDOG_NOTICE, + 'link' => '', + 'user' => $this->big_user, + 'uid' => isset($this->big_user->uid) ? $this->big_user->uid : 0, + 'request_uri' => $base_root . request_uri(), + 'referer' => $_SERVER['HTTP_REFERER'], + 'ip' => ip_address(), + 'timestamp' => REQUEST_TIME, + ); + dblog_watchdog($log); + + // View the database log report page. + $this->drupalGet('admin/reports/dblog'); + $this->assertResponse(200); + $output = truncate_utf8(filter_xss(t('Log data is corrupted and cannot be unserialized: Log entry added to test unserialize failure.'), array()), 56, TRUE, TRUE); + $this->assertText($output, 'Log data is corrupted and cannot be unserialized.'); + } + /** * Verifies setting of the database log row limit. * diff --git a/modules/field/modules/field_sql_storage/field_sql_storage.module b/modules/field/modules/field_sql_storage/field_sql_storage.module index deb08d0dac6..63161ab7d1d 100644 --- a/modules/field/modules/field_sql_storage/field_sql_storage.module +++ b/modules/field/modules/field_sql_storage/field_sql_storage.module @@ -212,6 +212,18 @@ function _field_sql_storage_schema($field) { ), ); + // If the target entity type uses a string for its entity ID then update + // the fields entity_id and revision_id columns from INT to VARCHAR. + if (!empty($field['entity_id_type']) && $field['entity_id_type'] === 'string') { + $current['fields']['entity_id']['type'] = 'varchar'; + $current['fields']['entity_id']['length'] = 128; + unset($current['fields']['entity_id']['unsigned']); + + $current['fields']['revision_id']['type'] = 'varchar'; + $current['fields']['revision_id']['length'] = 128; + unset($current['fields']['revision_id']['unsigned']); + } + $field += array('columns' => array(), 'indexes' => array(), 'foreign keys' => array()); // Add field columns. foreach ($field['columns'] as $column_name => $attributes) { diff --git a/modules/field/modules/field_sql_storage/field_sql_storage.test b/modules/field/modules/field_sql_storage/field_sql_storage.test index e46677be9c2..ad8d74926b9 100644 --- a/modules/field/modules/field_sql_storage/field_sql_storage.test +++ b/modules/field/modules/field_sql_storage/field_sql_storage.test @@ -104,6 +104,29 @@ class FieldSqlStorageTestCase extends DrupalWebTestCase { $this->assertFalse(array_key_exists($unavailable_language, $entity->{$this->field_name}), 'Field translation in an unavailable language ignored'); } + /** + * Tests adding a field with an entity ID type of string. + */ + function testFieldSqlSchemaForEntityWithStringIdentifier() { + // Test programmatically adding field with string ID. + $field_name = 'string_id_example'; + $field = array('field_name' => $field_name, 'type' => 'text', 'settings' => array('max_length' => 255), 'entity_id_type' => 'string'); + field_create_field($field); + $schema = drupal_get_schema('field_data_' . $field_name); + + $this->assertEqual($schema['fields']['entity_id']['type'], 'varchar'); + $this->assertEqual($schema['fields']['revision_id']['type'], 'varchar'); + + // Test programmatically adding field with default ID(int). + $field_name = 'default_id_example'; + $field = array('field_name' => $field_name, 'type' => 'text', 'settings' => array('max_length' => 255)); + field_create_field($field); + $schema = drupal_get_schema('field_data_' . $field_name); + + $this->assertEqual($schema['fields']['entity_id']['type'], 'int'); + $this->assertEqual($schema['fields']['revision_id']['type'], 'int'); + } + /** * Reads mysql to verify correct data is * written when using insert and update. diff --git a/modules/field/modules/text/text.module b/modules/field/modules/text/text.module index bf0d29d5a1f..d64eef9a681 100644 --- a/modules/field/modules/text/text.module +++ b/modules/field/modules/text/text.module @@ -348,6 +348,11 @@ function _text_sanitize($instance, $langcode, $item, $column) { */ function text_summary($text, $format = NULL, $size = NULL) { + // If the input text is NULL, return unchanged. + if (is_null($text)) { + return NULL; + } + if (!isset($size)) { // What used to be called 'teaser' is now called 'summary', but // the variable 'teaser_length' is preserved for backwards compatibility. diff --git a/modules/field/modules/text/text.test b/modules/field/modules/text/text.test index ad803cf46d9..0802c711947 100644 --- a/modules/field/modules/text/text.test +++ b/modules/field/modules/text/text.test @@ -378,6 +378,14 @@ class TextSummaryTestCase extends DrupalWebTestCase { } } + /** + * Test for the NULL value. + */ + function testNullSentence() { + $summary = text_summary(NULL); + $this->assertNull($summary, 'text_summary() casts returned null'); + } + /** * Calls text_summary() and asserts that the expected teaser is returned. */ diff --git a/modules/field/tests/field.test b/modules/field/tests/field.test index 5312f2d4557..c334661aa10 100644 --- a/modules/field/tests/field.test +++ b/modules/field/tests/field.test @@ -3788,4 +3788,16 @@ class EntityPropertiesTestCase extends FieldTestCase { } } } + + /** + * Tests entity_extract_ids() with an empty entity info. + */ + function testEntityKeys(){ + $entity_type = 'test_entity2'; + $entity = field_test_create_stub_entity(); + list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity); + + $this->assertNull($id, 'Entity id for test_entity2 returned NULL.'); + $this->assertNull($vid, 'Entity vid for test_entity2 returned NULL.'); + } } diff --git a/modules/field_ui/field_ui.admin.inc b/modules/field_ui/field_ui.admin.inc index 1bdaa45ddce..8ae489f3036 100644 --- a/modules/field_ui/field_ui.admin.inc +++ b/modules/field_ui/field_ui.admin.inc @@ -795,6 +795,14 @@ function field_ui_field_overview_form_submit($form, &$form_state) { $destinations = array(); + // Check if the target entity uses a non numeric ID. + $entity_info = entity_get_info($entity_type); + if (!empty($entity_info['entity_id_type']) && $entity_info['entity_id_type'] === 'string') { + $entity_id_type = 'string'; + } else { + $entity_id_type = NULL; + } + // Create new field. $field = array(); if (!empty($form_values['_add_new_field']['field_name'])) { @@ -804,6 +812,7 @@ function field_ui_field_overview_form_submit($form, &$form_state) { 'field_name' => $values['field_name'], 'type' => $values['type'], 'translatable' => $values['translatable'], + 'entity_id_type' => $entity_id_type, ); $instance = array( 'field_name' => $field['field_name'], diff --git a/modules/file/file.install b/modules/file/file.install index 47ee4fd0014..e0d33a04e20 100644 --- a/modules/file/file.install +++ b/modules/file/file.install @@ -53,18 +53,34 @@ function file_requirements($phase) { // Check the server's ability to indicate upload progress. if ($phase == 'runtime') { - $implementation = file_progress_implementation(); - $apache = strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== FALSE; - $fastcgi = strpos($_SERVER['SERVER_SOFTWARE'], 'mod_fastcgi') !== FALSE || strpos($_SERVER["SERVER_SOFTWARE"], 'mod_fcgi') !== FALSE; $description = NULL; - if (!$apache) { + $implementation = file_progress_implementation(); + // Test the web server identity. + $server_software = $_SERVER['SERVER_SOFTWARE']; + if (preg_match("/Nginx/i", $server_software)) { + $is_nginx = TRUE; + $is_apache = FALSE; + $fastcgi = FALSE; + } + elseif (preg_match("/Apache/i", $server_software)) { + $is_nginx = FALSE; + $is_apache = TRUE; + $fastcgi = strpos($server_software, 'mod_fastcgi') !== FALSE || strpos($server_software, 'mod_fcgi') !== FALSE; + } + else { + $is_nginx = FALSE; + $is_apache = FALSE; + $fastcgi = FALSE; + } + + if (!$is_apache && !$is_nginx) { $value = t('Not enabled'); - $description = t('Your server is not capable of displaying file upload progress. File upload progress requires an Apache server running PHP with mod_php.'); + $description = t('Your server is not capable of displaying file upload progress. File upload progress requires an Apache server running PHP with mod_php or Nginx with PHP-FPM.'); $severity = REQUIREMENT_INFO; } elseif ($fastcgi) { $value = t('Not enabled'); - $description = t('Your server is not capable of displaying file upload progress. File upload progress requires PHP be run with mod_php and not as FastCGI.'); + $description = t('Your server is not capable of displaying file upload progress. File upload progress requires PHP be run with mod_php or PHP-FPM and not as FastCGI.'); $severity = REQUIREMENT_INFO; } elseif (!$implementation && extension_loaded('apc')) { diff --git a/modules/image/image.module b/modules/image/image.module index 5af749779cd..728fad87b5f 100644 --- a/modules/image/image.module +++ b/modules/image/image.module @@ -588,8 +588,18 @@ function image_styles() { $style['storage'] = IMAGE_STORAGE_DEFAULT; foreach ($style['effects'] as $key => $effect) { $definition = image_effect_definition_load($effect['name']); - $effect = array_merge($definition, $effect); - $style['effects'][$key] = $effect; + if ($definition) { + $effect = array_merge($definition, $effect); + $style['effects'][$key] = $effect; + } + else { + watchdog('image', 'Image style %style_name has an effect %effect_name with no definition.', + array( + '%style_name' => $style_name, + '%effect_name' => $effect['name'], + ), + WATCHDOG_ERROR); + } } $styles[$style_name] = $style; } diff --git a/modules/image/image.test b/modules/image/image.test index 07eb4cedc1a..87f4168ae4d 100644 --- a/modules/image/image.test +++ b/modules/image/image.test @@ -31,7 +31,12 @@ class ImageFieldTestCase extends DrupalWebTestCase { protected $admin_user; function setUp() { - parent::setUp('image'); + $modules = func_get_args(); + if (isset($modules[0]) && is_array($modules[0])) { + $modules = $modules[0]; + } + $modules[] = 'image'; + parent::setUp($modules); $this->admin_user = $this->drupalCreateUser(array('access content', 'access administration pages', 'administer site configuration', 'administer content types', 'administer nodes', 'create article content', 'edit any article content', 'delete any article content', 'administer image styles', 'administer fields')); $this->drupalLogin($this->admin_user); } @@ -573,6 +578,10 @@ class ImageAdminStylesUnitTest extends ImageFieldTestCase { ); } + function setUp() { + parent::setUp('image_module_test', 'image_module_styles_test'); + } + /** * Given an image style, generate an image. */ @@ -893,6 +902,18 @@ class ImageAdminStylesUnitTest extends ImageFieldTestCase { $this->drupalGet('node/' . $nid); $this->assertRaw(check_plain(image_style_url('thumbnail', $node->{$field_name}[LANGUAGE_NONE][0]['uri'])), format_string('Image displayed using style replacement style.')); } + + /** + * Test disabling a module providing an effect in use by an image style. + */ + function testOrphanedEffect() { + // This will not check whether anything depends on the module. + module_disable(array('image_module_test'), FALSE); + $this->drupalGet('admin/config/media/image-styles'); + $this->assertText('Test Image Style', 'Image style with an orphaned effect displayed in the list of styles.'); + $image_log = db_query_range('SELECT message FROM {watchdog} WHERE type = :type ORDER BY wid DESC', 0, 1, array(':type' => 'image'))->fetchField(); + $this->assertEqual('Image style %style_name has an effect %effect_name with no definition.', $image_log, 'A watchdog message was logged for the broken image style effect'); + } } /** diff --git a/modules/image/tests/image_module_styles_test.info b/modules/image/tests/image_module_styles_test.info new file mode 100644 index 00000000000..b2acc9b3d8e --- /dev/null +++ b/modules/image/tests/image_module_styles_test.info @@ -0,0 +1,8 @@ +name = Image Styles test +description = Provides additional hook implementations for testing Image Styles functionality. +package = Core +version = VERSION +core = 7.x +files[] = image_module_styles_test.module +dependencies[] = image_module_test +hidden = TRUE diff --git a/modules/image/tests/image_module_styles_test.module b/modules/image/tests/image_module_styles_test.module new file mode 100644 index 00000000000..79929d93992 --- /dev/null +++ b/modules/image/tests/image_module_styles_test.module @@ -0,0 +1,29 @@ + 'Test Image Style', + 'effects' => array( + array( + 'name' => 'image_scale', + 'data' => array('width' => 100, 'height' => 100, 'upscale' => 1), + 'weight' => 0, + ), + array( + 'name' => 'image_module_test_null', + ), + ) + ); + + return $styles; +} diff --git a/modules/image/tests/image_module_test.module b/modules/image/tests/image_module_test.module index fc66d9b8b7c..e7ae716e7a7 100644 --- a/modules/image/tests/image_module_test.module +++ b/modules/image/tests/image_module_test.module @@ -20,7 +20,8 @@ function image_module_test_file_download($uri) { function image_module_test_image_effect_info() { $effects = array( 'image_module_test_null' => array( - 'effect callback' => 'image_module_test_null_effect', + 'label' => 'image_module_test_null', + 'effect callback' => 'image_module_test_null_effect', ), ); @@ -38,7 +39,7 @@ function image_module_test_image_effect_info() { * @return * TRUE */ -function image_module_test_null_effect(array &$image, array $data) { +function image_module_test_null_effect(&$image, array $data) { return TRUE; } diff --git a/modules/locale/locale.test b/modules/locale/locale.test index b890b06147c..4f6fd6c3038 100644 --- a/modules/locale/locale.test +++ b/modules/locale/locale.test @@ -297,6 +297,28 @@ class LocaleJavascriptTranslationTest extends DrupalWebTestCase { $this->assertEqual(count($source_strings), count($test_strings), 'Found correct number of source strings.'); } + + /** + * Test handling of null values in JS parsing for PHP8.0+ deprecations. + */ + function testNullValuesLocalesSource() { + db_insert('locales_source') + ->fields(array( + 'location' => NULL, + 'source' => 'Standard Call t', + 'context' => '', + 'textgroup' => 'default', + )) + ->execute(); + + $filename = drupal_get_path('module', 'locale_test') . '/locale_test.js'; + + // Parse the file to look for source strings. + _locale_parse_js_file($filename); + + $num_records = db_select('locales_source')->fields(NULL, array('lid'))->countQuery()->execute()->fetchField(); + $this->assertEqual($num_records, 32, 'Correct number of strings parsed from JS file'); + } } /** * Functional test for string translation and validation. diff --git a/modules/simpletest/tests/bootstrap.test b/modules/simpletest/tests/bootstrap.test index 61caf53caeb..6bee03e12f4 100644 --- a/modules/simpletest/tests/bootstrap.test +++ b/modules/simpletest/tests/bootstrap.test @@ -187,6 +187,7 @@ class BootstrapPageCacheTestCase extends DrupalWebTestCase { $this->assertEqual($this->drupalGetHeader('Cache-Control'), 'public, max-age=0', 'Cache-Control header was sent.'); $this->assertEqual($this->drupalGetHeader('Expires'), 'Sun, 19 Nov 1978 05:00:00 GMT', 'Expires header was sent.'); $this->assertEqual($this->drupalGetHeader('Foo'), 'bar', 'Custom header was sent.'); + $this->assertEqual($this->drupalGetHeader('X-Content-Type-Options'), 'nosniff', 'X-Content-Type-Options header was sent.'); // Check replacing default headers. $this->drupalGet('system-test/set-header', array('query' => array('name' => 'Expires', 'value' => 'Fri, 19 Nov 2008 05:00:00 GMT'))); @@ -236,6 +237,9 @@ class BootstrapPageCacheTestCase extends DrupalWebTestCase { $this->assertTitle(t('Welcome to @site-name | @site-name', array('@site-name' => variable_get('site_name', 'Drupal'))), 'Site title matches.'); $this->assertRaw('', 'Page was not compressed.'); + // Verify that an empty page doesn't throw an error when being decompressed. + $this->drupalGet('system-test/empty-page'); + // Disable compression mode. variable_set('page_compression', FALSE); @@ -248,6 +252,27 @@ class BootstrapPageCacheTestCase extends DrupalWebTestCase { $this->drupalGet(''); $this->assertRaw('', 'Page was delivered after compression mode is changed (compression support disabled).'); } + + /** + * Test page cache headers. + */ + function testPageCacheHeaders() { + variable_set('cache', 1); + // First request should store a response in the page cache. + $this->drupalGet('system-test/page-cache-headers'); + + // The test callback should remove the query string leaving the same path + // as the previous request, which we'll try to retrieve from cache_page. + $this->drupalGet('system-test/page-cache-headers', array('query' => array('return_headers' => 'TRUE'))); + + $headers = json_decode($this->drupalGetHeader('Page-Cache-Headers'), TRUE); + if (is_null($headers)) { + $this->fail('No headers were retrieved from the page cache.'); + } + else { + $this->assertEqual($headers['X-Content-Type-Options'], 'nosniff', 'X-Content-Type-Options header retrieved from response in the page cache.'); + } + } } class BootstrapVariableTestCase extends DrupalWebTestCase { diff --git a/modules/simpletest/tests/common.test b/modules/simpletest/tests/common.test index 3b4d731da3c..d7298c367f0 100644 --- a/modules/simpletest/tests/common.test +++ b/modules/simpletest/tests/common.test @@ -480,11 +480,15 @@ class CommonXssUnitTest extends DrupalUnitTestCase { * Check that invalid multi-byte sequences are rejected. */ function testInvalidMultiByte() { - // Ignore PHP 8.0+ null deprecatations. + // Ignore PHP 8.0+ null deprecations. $text = check_plain(NULL); $this->assertEqual($text, '', 'check_plain() casts null to string'); $text = check_plain(FALSE); $this->assertEqual($text, '', 'check_plain() casts boolean to string'); + $text = filter_xss(NULL); + $this->assertEqual($text, '', 'filter_xss() casts null to string'); + $text = filter_xss(FALSE); + $this->assertEqual($text, '', 'filter_xss() casts boolean to string'); // Ignore PHP 5.3+ invalid multibyte sequence warning. $text = @check_plain("Foo\xC0barbaz"); $this->assertEqual($text, '', 'check_plain() rejects invalid sequence "Foo\xC0barbaz"'); diff --git a/modules/simpletest/tests/entity_crud.test b/modules/simpletest/tests/entity_crud.test index be15977902a..c7d66509a25 100644 --- a/modules/simpletest/tests/entity_crud.test +++ b/modules/simpletest/tests/entity_crud.test @@ -46,4 +46,16 @@ class EntityLoadTestCase extends DrupalWebTestCase { $this->assertIdentical($nodes_loaded[$node_2->nid], $all_nodes[$node_2->nid], 'Loaded node 2 is identical to cached node.'); $this->assertIdentical($nodes_loaded[$node_3->nid], $all_nodes[$node_3->nid], 'Loaded node 3 is identical to cached node.'); } + + public function testEntityLoadIds() { + $this->drupalCreateNode(array('title' => 'Node 1')); + $this->drupalCreateNode(array('title' => 'Node 2')); + + $nodes_loaded = entity_load('node', array('1', '2')); + $this->assertEqual(count($nodes_loaded), 2); + + // Ensure that an id with a trailing decimal place is ignored. + $nodes_loaded = entity_load('node', array('1.', '2')); + $this->assertEqual(count($nodes_loaded), 1); + } } diff --git a/modules/simpletest/tests/module.test b/modules/simpletest/tests/module.test index 25dbb67bf7e..cca971c50b3 100644 --- a/modules/simpletest/tests/module.test +++ b/modules/simpletest/tests/module.test @@ -122,6 +122,19 @@ class ModuleUnitTest extends DrupalWebTestCase { $this->assertText('System null version test', 'Module admin UI listed dependency with null version successfully.'); } + /** + * Test system_modules() with a module with a broken configure path. + */ + function testSystemModulesBrokenConfigure() { + module_enable(array('system_admin_test')); + $this->resetAll(); + $admin = $this->drupalCreateUser(array('administer modules')); + $this->drupalLogin($admin); + $this->drupalGet('admin/modules'); + $module_log = db_query_range('SELECT message FROM {watchdog} WHERE type = :type ORDER BY wid DESC', 0, 1, array(':type' => 'system'))->fetchField(); + $this->assertEqual('Module %module specifies an invalid path for configuration: %configure', $module_log, 'An error was logged for the module\'s broken configure path.'); + } + /** * Test that module_invoke() can load a hook defined in hook_hook_info(). */ diff --git a/modules/simpletest/tests/system_admin_test.info b/modules/simpletest/tests/system_admin_test.info new file mode 100644 index 00000000000..5310174ed7a --- /dev/null +++ b/modules/simpletest/tests/system_admin_test.info @@ -0,0 +1,7 @@ +name = System Admin Test +description = 'Support module for testing system.admin.inc.' +package = Only For Testing +version = VERSION +core = 7.x +hidden = FALSE +configure = config/broken diff --git a/modules/simpletest/tests/system_admin_test.module b/modules/simpletest/tests/system_admin_test.module new file mode 100644 index 00000000000..ae61e749513 --- /dev/null +++ b/modules/simpletest/tests/system_admin_test.module @@ -0,0 +1,6 @@ + MENU_CALLBACK, ); + $items['system-test/empty-page'] = array( + 'title' => 'Test page cache with empty page.', + 'page callback' => 'system_test_empty_page', + 'access callback' => TRUE, + 'type' => MENU_CALLBACK, + ); + + $items['system-test/page-cache-headers'] = array( + 'page callback' => 'system_test_page_cache_headers', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + return $items; } @@ -223,6 +236,28 @@ function system_test_redirect_invalid_scheme() { exit; } +/** + * Menu callback to test headers stored in the page cache. + */ +function system_test_page_cache_headers() { + if (!isset($_GET['return_headers'])) { + return t('Content to store in the page cache if it is enabled.'); + } + global $base_root; + // Remove the test query param but try to preserve any remaining query string. + $url = parse_url($base_root . request_uri()); + $query_parts = explode('&', $url['query']); + $query_string = implode('&', array_diff($query_parts, array('return_headers=TRUE'))); + $request_uri = $url['path'] . '?' . $query_string; + $cache = cache_get($base_root . $request_uri, 'cache_page'); + // If there are any headers stored in the cache, output them. + if (isset($cache->data['headers'])) { + drupal_add_http_header('Page-Cache-Headers', json_encode($cache->data['headers'])); + return 'Headers from cache_page returned in the Page-Cache-Headers http response header.'; + } + return 'No headers retrieved from cache_page.'; +} + /** * Implements hook_modules_installed(). */ @@ -532,6 +567,12 @@ function system_test_drupal_get_filename_with_schema_rebuild() { return ''; } +/** + * Page callback to output an empty page. + */ +function system_test_empty_page() { +} + /** * Implements hook_watchdog(). */ diff --git a/modules/simpletest/tests/upgrade/upgrade.test b/modules/simpletest/tests/upgrade/upgrade.test index 707431ea1d7..68eec60cef0 100644 --- a/modules/simpletest/tests/upgrade/upgrade.test +++ b/modules/simpletest/tests/upgrade/upgrade.test @@ -362,6 +362,9 @@ class BasicUpgradePath extends UpgradePathTestCase { * Test a failed upgrade, and verify that the failure is reported. */ public function testFailedUpgrade() { + if ($this->skipUpgradeTest) { + return; + } // Destroy a table that the upgrade process needs. db_drop_table('access'); // Assert that the upgrade fails. @@ -372,6 +375,9 @@ class BasicUpgradePath extends UpgradePathTestCase { * Test a successful upgrade. */ public function testBasicUpgrade() { + if ($this->skipUpgradeTest) { + return; + } $this->assertTrue($this->performUpgrade(), 'The upgrade was completed successfully.'); // Hit the frontpage. diff --git a/modules/system/system.admin.inc b/modules/system/system.admin.inc index 40908c1a277..84e7fef182b 100644 --- a/modules/system/system.admin.inc +++ b/modules/system/system.admin.inc @@ -886,7 +886,13 @@ function system_modules($form, $form_state = array()) { // one. if ($module->status && isset($module->info['configure'])) { $configure_link = menu_get_item($module->info['configure']); - if ($configure_link['access']) { + if ($configure_link === FALSE) { + watchdog('system', 'Module %module specifies an invalid path for configuration: %configure', array( + '%module' => $module->info['name'], + '%configure' => $module->info['configure'], + )); + } + else if ($configure_link['access']) { $extra['links']['configure'] = array( '#type' => 'link', '#title' => t('Configure'), @@ -929,6 +935,8 @@ function system_modules($form, $form_state = array()) { ), // Ensure that the "Core" package fieldset comes first. '#weight' => $package == 'Core' ? -10 : NULL, + // Hide this package unless we're running a test. + '#access' => !($package == 'Only For Testing' && !drupal_valid_test_ua()), ); } @@ -2358,6 +2366,10 @@ function system_status($check = FALSE) { * Menu callback: run cron manually. */ function system_run_cron() { + if (!isset($_GET['token']) || !drupal_valid_token($_GET['token'], 'run-cron')) { + return MENU_ACCESS_DENIED; + } + // Run cron manually if (drupal_cron_run()) { drupal_set_message(t('Cron ran successfully.')); diff --git a/modules/system/system.api.php b/modules/system/system.api.php index 8f5cbc04888..0e2b05b4e14 100644 --- a/modules/system/system.api.php +++ b/modules/system/system.api.php @@ -3195,7 +3195,7 @@ function hook_requirements($phase) { ); } - $requirements['cron']['description'] .= ' ' . $t('You can run cron manually.', array('@cron' => url('admin/reports/status/run-cron'))); + $requirements['cron']['description'] .= ' ' . $t('You can run cron manually.', array('@cron' => url('admin/reports/status/run-cron', array('query' => array('token' => drupal_get_token('run-cron')))))); $requirements['cron']['title'] = $t('Cron maintenance tasks'); } diff --git a/modules/system/system.install b/modules/system/system.install index b1f628f6062..57b4e5a7109 100644 --- a/modules/system/system.install +++ b/modules/system/system.install @@ -351,7 +351,7 @@ function system_requirements($phase) { $description = $t('Cron has not run recently.') . ' ' . $help; } - $description .= ' ' . $t('You can run cron manually.', array('@cron' => url('admin/reports/status/run-cron'))); + $description .= ' ' . $t('You can run cron manually.', array('@cron' => url('admin/reports/status/run-cron', array('query' => array('token' => drupal_get_token('run-cron')))))); $description .= '
' . $t('To run cron from outside the site, go to !cron', array('!cron' => url($base_url . '/cron.php', array('external' => TRUE, 'query' => array('cron_key' => variable_get('cron_key', 'drupal')))))); $requirements['cron'] = array( diff --git a/modules/system/system.test b/modules/system/system.test index 865a0e6b86b..919fcf70bcc 100644 --- a/modules/system/system.test +++ b/modules/system/system.test @@ -925,6 +925,22 @@ class CronRunTestCase extends DrupalWebTestCase { $this->assertEqual($result, 'success', 'Cron correctly handles exceptions thrown during hook_cron() invocations.'); } + /** + * Ensure that the manual cron run is working. + */ + function testManualCron() { + $admin_user = $this->drupalCreateUser(array('administer site configuration')); + $this->drupalLogin($admin_user); + + $this->drupalGet('admin/reports/status/run-cron'); + $this->assertResponse(403); + + $this->drupalGet('admin/reports/status'); + $this->clickLink(t('run cron manually')); + $this->assertResponse(200); + $this->assertText(t('Cron ran successfully.')); + } + /** * Tests that hook_flush_caches() is not invoked on every single cron run. * diff --git a/modules/update/update.module b/modules/update/update.module index 869c5ef0409..cbd68514f9b 100644 --- a/modules/update/update.module +++ b/modules/update/update.module @@ -367,8 +367,9 @@ function update_cache_clear_submit($form, &$form_state) { */ function _update_no_data() { $destination = drupal_get_destination(); + $cron_token = array('token' => drupal_get_token('run-cron')); return t('No update information available. Run cron or check manually.', array( - '@run_cron' => url('admin/reports/status/run-cron', array('query' => $destination)), + '@run_cron' => url('admin/reports/status/run-cron', array('query' => $cron_token + $destination)), '@check_manually' => url('admin/reports/updates/check', array('query' => $destination)), )); } diff --git a/modules/user/user.test b/modules/user/user.test index 1c3155924e0..e799183ac4f 100644 --- a/modules/user/user.test +++ b/modules/user/user.test @@ -2566,6 +2566,25 @@ class UserTokenReplaceTestCase extends DrupalWebTestCase { $this->assertEqual($output, $expected, format_string('Unsanitized user token %token replaced.', array('%token' => $input))); } } + + /** + * Uses an anonymous user, then tests the tokens generated from it. + */ + function testAnonymousUserTokenReplacement() { + global $language; + + // Load anonymous user data. + $account = drupal_anonymous_user(); + + // Generate and test sanitized tokens. + $tests = array(); + $tests['[user:mail]'] = ''; + + foreach ($tests as $input => $expected) { + $output = token_replace($input, array('user' => $account), array('language' => $language)); + $this->assertEqual($output, $expected, format_string('Sanitized user token %token replaced.', array('%token' => $input))); + } + } } /** diff --git a/modules/user/user.tokens.inc b/modules/user/user.tokens.inc index 55acfbf5cce..a372696da4d 100644 --- a/modules/user/user.tokens.inc +++ b/modules/user/user.tokens.inc @@ -96,7 +96,12 @@ function user_tokens($type, $tokens, array $data = array(), array $options = arr break; case 'mail': - $replacements[$original] = $sanitize ? check_plain($account->mail) : $account->mail; + if (empty($account->mail)) { + $replacements[$original] = ''; + } + else { + $replacements[$original] = $sanitize ? check_plain($account->mail) : $account->mail; + } break; case 'url':