From ee03d40d4e285c5c055ff3d05f77b0819b6ac43b Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 27 Jul 2026 08:32:27 -0700 Subject: [PATCH 01/11] Encode or reject 4-byte emoji as needed --- attachment/attachment.php | 10 ++- attachment/uploader.php | 11 ++++ contribution/extension/type.php | 5 +- contribution/translation/type.php | 2 +- controller/contribution/revision.php | 12 +++- controller/contribution/revision_edit.php | 11 ++++ emoji.php | 78 +++++++++++++++++++++++ entity/database_base.php | 16 ++++- includes/objects/attention.php | 4 +- includes/objects/category.php | 5 ++ includes/objects/contribution.php | 62 ++++++++++++++++-- includes/objects/faq.php | 2 +- includes/objects/post.php | 4 +- includes/objects/topic.php | 6 +- language/en/common.php | 1 + language/en/contributions.php | 2 + manage/tool/composer/rebuild_repo.php | 2 + posting.php | 2 +- url/url.php | 1 + 19 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 emoji.php diff --git a/attachment/attachment.php b/attachment/attachment.php index 412376c16..cdecc2a82 100644 --- a/attachment/attachment.php +++ b/attachment/attachment.php @@ -95,7 +95,7 @@ protected function configure_properties() 'physical_filename' => array('default' => '', 'max' => 255), 'attachment_directory' => array('default' => '', 'max' => 255), 'real_filename' => array('default' => '', 'max' => 255), - 'attachment_comment' => array('default' => ''), + 'attachment_comment' => array('default' => '', 'encode_ucr' => true), 'download_count' => array('default' => 0), @@ -202,6 +202,14 @@ public function submit($data = null) if (!empty($data)) { + foreach ($data as $name => $value) + { + if (isset($this->object_config[$name])) + { + $data[$name] = $this->validate_property($value, $this->object_config[$name]); + } + } + $sql = 'UPDATE ' . $this->sql_table . ' SET ' . $this->db->sql_build_array('UPDATE', $data) . ' WHERE attachment_id = ' . $this->get_id(); diff --git a/attachment/uploader.php b/attachment/uploader.php index d22820f02..4ed527239 100644 --- a/attachment/uploader.php +++ b/attachment/uploader.php @@ -16,6 +16,7 @@ use phpbb\files\upload; use phpbb\request\request_interface; use phpbb\titania\access; +use phpbb\titania\emoji; use phpbb\titania\ext; class uploader @@ -260,6 +261,16 @@ public function upload_file() return false; } + if (emoji::contains($file->get('uploadname'))) + { + $file->error[] = $this->user->lang('INVALID_FILENAME', $file->get('uploadname')); + $file->remove(); + $this->filedata['error'] = array_merge($this->filedata['error'], $file->error); + $this->filedata['post_attach'] = false; + + return false; + } + // Set max file size for anyone but team members. if (!$this->access->is_team()) { diff --git a/contribution/extension/type.php b/contribution/extension/type.php index 2d93f7398..a8124fd04 100644 --- a/contribution/extension/type.php +++ b/contribution/extension/type.php @@ -18,6 +18,7 @@ use phpbb\titania\attachment\attachment; use phpbb\titania\config\config as ext_config; use phpbb\titania\contribution\type\base; +use phpbb\titania\emoji; use phpbb\titania\entity\package; use phpbb\user; @@ -283,10 +284,10 @@ protected function repack(package $package, \titania_contribution $contrib, \tit $data = $this->update_phpbb_requirement($data, $revision); $data = $this->set_version_check($data, $contrib); - $data = json_encode( + $data = emoji::escape_json(json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE - ); + )); file_put_contents($composer_file, $data); $package->restore_root($ext_base_path, $ext_name); diff --git a/contribution/translation/type.php b/contribution/translation/type.php index fdf5cbbf0..1b0b1b5b1 100644 --- a/contribution/translation/type.php +++ b/contribution/translation/type.php @@ -173,7 +173,7 @@ public function translation_validate(\titania_contribution $contrib, \titania_re // Save the translation validation results (we need to save it here so that we can add it to the post later) $queue = $revision->get_queue(); - $queue->tv_results = $translation_validator_output; + $queue->tv_results = utf8_encode_ucr($translation_validator_output); $queue->submit(); return array( diff --git a/controller/contribution/revision.php b/controller/contribution/revision.php index f163ccd4a..a9a434a7d 100644 --- a/controller/contribution/revision.php +++ b/controller/contribution/revision.php @@ -15,6 +15,7 @@ use phpbb\titania\composer\repository; use phpbb\titania\contribution\type\collection as type_collection; +use phpbb\titania\emoji; use phpbb\titania\ext; use Symfony\Component\HttpFoundation\JsonResponse; @@ -589,7 +590,7 @@ protected function create_queue_item($allow_repack, $test_account) { $this->queue->queue_notes .= "\n\n[b]" . $this->user->lang('TEST_ACCOUNT') . "[/b]\n" . - $test_account + utf8_encode_ucr($test_account) ; } $this->queue->submit(); @@ -615,6 +616,15 @@ protected function validate_settings($settings) $error[] = $this->user->lang['NO_REVISION_VERSION']; } + if ( + emoji::contains($settings['name']) || + emoji::contains($settings['version']) || + emoji::contains($settings['license']) + ) + { + $error[] = $this->user->lang['REVISION_EMOJI_NOT_ALLOWED']; + } + if (!empty($this->contrib->type->license_options) && !$this->contrib->type->license_allow_custom && !in_array($settings['license'], $this->contrib->type->license_options)) { diff --git a/controller/contribution/revision_edit.php b/controller/contribution/revision_edit.php index 05d7107c7..c8d678500 100644 --- a/controller/contribution/revision_edit.php +++ b/controller/contribution/revision_edit.php @@ -14,6 +14,7 @@ namespace phpbb\titania\controller\contribution; use phpbb\titania\contribution\type\collection as type_collection; +use phpbb\titania\emoji; use phpbb\titania\ext; use Symfony\Component\HttpFoundation\JsonResponse; @@ -243,6 +244,16 @@ protected function validate_settings($settings) { $error[] = $this->user->lang['FORM_INVALID']; } + + if ( + emoji::contains($settings['name']) || + emoji::contains($settings['license']) || + emoji::contains($settings['custom_license']) + ) + { + $error[] = $this->user->lang['REVISION_EMOJI_NOT_ALLOWED']; + } + $license_options = ($this->contrib->type->license_options) ?: array(); if ($license_options && !$this->contrib->type->license_allow_custom && !in_array($settings['license'], $license_options)) diff --git a/emoji.php b/emoji.php new file mode 100644 index 000000000..ec70b88d7 --- /dev/null +++ b/emoji.php @@ -0,0 +1,78 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\titania; + +class emoji +{ + /** + * Escape four-byte UTF-8 characters as JSON surrogate pairs. + * + * This leaves the structure and formatting of the JSON document intact. + * + * @param string $json + * @return string + */ + public static function escape_json($json) + { + return preg_replace_callback( + '/[\xF0-\xF4][\x80-\xBF]{3}/', + function ($matches) + { + $codepoint = utf8_ord($matches[0]) - 0x10000; + $high_surrogate = 0xD800 + ($codepoint >> 10); + $low_surrogate = 0xDC00 + ($codepoint & 0x3FF); + + return sprintf('\\u%04X\\u%04X', $high_surrogate, $low_surrogate); + }, + $json + ); + } + + /** + * Check whether a string contains emoji or another four-byte character + * unsupported by phpBB's MySQL utf8 schema. + * + * @param string $value + * @return bool + */ + public static function contains($value) + { + $value = utf8_decode_ncr($value); + $emoji_pattern = '/[' . + '\x{00A9}\x{00AE}\x{200D}\x{203C}\x{2049}\x{20E3}\x{2122}\x{2139}' . + '\x{2194}-\x{21FF}\x{2300}-\x{23FF}\x{24C2}\x{25AA}-\x{27BF}' . + '\x{2B00}-\x{2BFF}\x{3030}\x{303D}\x{3297}\x{3299}\x{FE0E}\x{FE0F}' . + '\x{10000}-\x{10FFFF}' . + ']/u'; + + return (bool) preg_match($emoji_pattern, $value); + } + + /** + * Remove four-byte characters unsupported by phpBB's MySQL utf8 schema. + * + * @param string $value + * @return string + */ + public static function strip_unsupported($value) + { + $value = utf8_decode_ncr($value); + + return preg_replace( + '/[\x{10000}-\x{10FFFF}]/u', + ' ', + $value + ); + } +} diff --git a/entity/database_base.php b/entity/database_base.php index b5323fb8c..913630861 100644 --- a/entity/database_base.php +++ b/entity/database_base.php @@ -255,7 +255,21 @@ protected function validate_string($value, $config) // Truncate to the maximum length if (isset($config['max']) && $config['max']) { - truncate_string($value, $config['max']); + $value = truncate_string($value, $config['max'], PHP_INT_MAX); + } + + if (!empty($config['encode_ucr'])) + { + $value = utf8_encode_ucr($value); + + // Numeric references use more storage characters than the + // characters they represent. + while (!empty($config['max']) && utf8_strlen($value) > $config['max']) + { + $length = utf8_strlen(html_entity_decode($value, ENT_COMPAT)); + $value = truncate_string($value, $length - 1, PHP_INT_MAX); + $value = utf8_encode_ucr($value); + } } return $value; diff --git a/includes/objects/attention.php b/includes/objects/attention.php index 6c8b8832d..a7398e087 100644 --- a/includes/objects/attention.php +++ b/includes/objects/attention.php @@ -66,8 +66,8 @@ public function __construct() 'attention_time' => array('default' => titania::$time), 'attention_close_time' => array('default' => 0), 'attention_close_user' => array('default' => 0), - 'attention_title' => array('default' => ''), - 'attention_description' => array('default' => ''), + 'attention_title' => array('default' => '', 'max' => 255, 'encode_ucr' => true), + 'attention_description' => array('default' => '', 'encode_ucr' => true), 'notify_reporter' => array('default' => 0), )); diff --git a/includes/objects/category.php b/includes/objects/category.php index 0062566ae..2d2ca9668 100644 --- a/includes/objects/category.php +++ b/includes/objects/category.php @@ -11,6 +11,7 @@ * */ +use phpbb\titania\emoji; use phpbb\titania\ext; use phpbb\titania\sync; @@ -702,6 +703,10 @@ public function validate() { $error[] = phpbb::$user->lang['NO_CATEGORY_NAME']; } + else if (emoji::contains($this->category_name)) + { + $error[] = phpbb::$user->lang['CATEGORY_EMOJI_NOT_ALLOWED']; + } if (!$this->category_name_clean || !preg_match('/^[a-zA-Z0-9\-\_]+$/', $this->category_name_clean)) { diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index 9818f7db0..f000702cf 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -15,6 +15,7 @@ use phpbb\titania\composer\repository; use phpbb\titania\contribution\type\collection as type_collection; use phpbb\titania\contribution\type\type_interface; +use phpbb\titania\emoji; use phpbb\titania\ext; use phpbb\titania\message\message; use phpbb\titania\url\url; @@ -1499,6 +1500,34 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $error[] = phpbb::$user->lang['EMPTY_CONTRIB_NAME']; } + $metadata = $this->__get_array(); + unset( + $metadata['contrib_desc'], + $metadata['contrib_desc_bitfield'], + $metadata['contrib_desc_uid'], + $metadata['contrib_desc_options'] + ); + $metadata = array_merge($metadata, $custom_fields); + + $demos = json_decode($this->contrib_demo, true); + if (is_array($demos)) + { + // JSON encoding can otherwise hide emoji behind surrogate escapes. + $metadata['contrib_demo'] = implode("\n", $demos); + } + + $metadata_has_emoji = false; + + foreach ($metadata as $value) + { + if (is_string($value) && emoji::contains($value)) + { + $metadata_has_emoji = true; + $error[] = phpbb::$user->lang['CONTRIB_EMOJI_NOT_ALLOWED']; + break; + } + } + if (!$this->contrib_type) { $error[] = phpbb::$user->lang['EMPTY_CONTRIB_TYPE']; @@ -1532,13 +1561,13 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $this->set_type($this->contrib_type); $error = array_merge($error, $this->type->validate_contrib_fields($custom_fields)); - if (!$this->contrib_name_clean) + if (!$metadata_has_emoji && !$this->contrib_name_clean) { // If they leave it blank automatically create it $this->generate_permalink(); } - if (($permalink_error = $this->validate_permalink($this->contrib_name_clean, $old_permalink)) !== false) + if (!$metadata_has_emoji && ($permalink_error = $this->validate_permalink($this->contrib_name_clean, $old_permalink)) !== false) { $error[] = $permalink_error; } @@ -1588,6 +1617,11 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $author = key($authors['author']); $missing_coauthors = array_merge($authors['missing']['active_coauthors'], $authors['missing']['nonactive_coauthors']); + if (!empty($authors['emoji']) && !$metadata_has_emoji) + { + $error[] = phpbb::$user->lang['CONTRIB_EMOJI_NOT_ALLOWED']; + } + if (!empty($missing_coauthors)) { $error[] = phpbb::$user->lang('COULD_NOT_FIND_USERS', phpbb_generate_string_list($missing_coauthors, phpbb::$user)); @@ -1696,13 +1730,29 @@ public function permalink_exists($permalink) */ public function get_authors_from_usernames($authors) { - $result = array('missing' => array()); + $result = array( + 'missing' => array(), + 'emoji' => false, + ); foreach ($authors as $group => $users) { - $users = user_helper::get_user_ids_from_list($this->db, $users); - $result[$group] = $users['ids']; - $result['missing'][$group] = $users['missing']; + $valid_users = array(); + + foreach (explode("\n", $users) as $username) + { + if (emoji::contains($username)) + { + $result['emoji'] = true; + continue; + } + + $valid_users[] = $username; + } + + $user_data = user_helper::get_user_ids_from_list($this->db, implode("\n", $valid_users)); + $result[$group] = $user_data['ids']; + $result['missing'][$group] = $user_data['missing']; } return $result; diff --git a/includes/objects/faq.php b/includes/objects/faq.php index 1a18939c3..3fcd954a4 100644 --- a/includes/objects/faq.php +++ b/includes/objects/faq.php @@ -62,7 +62,7 @@ public function __construct($faq_id = false) $this->object_config = array_merge($this->object_config, array( 'faq_id' => array('default' => 0), 'contrib_id' => array('default' => 0), - 'faq_subject' => array('default' => '', 'message_field' => 'subject', 'max' => 255), + 'faq_subject' => array('default' => '', 'message_field' => 'subject', 'max' => 255, 'encode_ucr' => true), 'faq_text' => array('default' => '', 'message_field' => 'message'), 'faq_text_bitfield' => array('default' => '', 'message_field' => 'message_bitfield'), 'faq_text_uid' => array('default' => '', 'message_field' => 'message_uid'), diff --git a/includes/objects/post.php b/includes/objects/post.php index a5dbc8391..15a0b1742 100644 --- a/includes/objects/post.php +++ b/includes/objects/post.php @@ -108,10 +108,10 @@ public function __construct($type = ext::TITANIA_SUPPORT, $topic = false, $post_ 'post_edit_time' => array('default' => 0), // The last time that user edit the post 'post_edit_user' => array('default' => 0), // The last user to edit the post - 'post_edit_reason' => array('default' => ''), // Reason for deleting/editing + 'post_edit_reason' => array('default' => '', 'max' => 255, 'encode_ucr' => true), // Reason for deleting/editing 'post_delete_user' => array('default' => 0), // The last user to delete the post - 'post_subject' => array('default' => '', 'message_field' => 'subject', 'max' => 255), + 'post_subject' => array('default' => '', 'message_field' => 'subject', 'max' => 255, 'encode_ucr' => true), 'post_text' => array('default' => '', 'message_field' => 'message'), 'post_text_bitfield' => array('default' => '', 'message_field' => 'message_bitfield'), 'post_text_uid' => array('default' => '', 'message_field' => 'message_uid'), diff --git a/includes/objects/topic.php b/includes/objects/topic.php index 208e091fc..ef0985d8a 100644 --- a/includes/objects/topic.php +++ b/includes/objects/topic.php @@ -89,8 +89,8 @@ public function __construct($topic_id = 0) 'topic_posts' => array('default' => ''), // Post count; separated by : between access levels ('10:9:8' = 10 team; 9 Mod Author; 8 Public) 'topic_views' => array('default' => 0), // View count - 'topic_subject' => array('default' => ''), - 'topic_subject_clean' => array('default' => ''), + 'topic_subject' => array('default' => '', 'max' => 255, 'encode_ucr' => true), + 'topic_subject_clean' => array('default' => '', 'max' => 255, 'encode_ucr' => true), 'topic_first_post_id' => array('default' => 0), 'topic_first_post_user_id' => array('default' => 0), @@ -103,7 +103,7 @@ public function __construct($topic_id = 0) 'topic_last_post_username' => array('default' => ''), 'topic_last_post_user_colour' => array('default' => ''), 'topic_last_post_time' => array('default' => (int) titania::$time), - 'topic_last_post_subject' => array('default' => ''), + 'topic_last_post_subject' => array('default' => '', 'max' => 255, 'encode_ucr' => true), 'phpbb_topic_id' => array('default' => 0), )); diff --git a/language/en/common.php b/language/en/common.php index e1a985e3b..30f81ca65 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -63,6 +63,7 @@ 'CATEGORY_DELETED' => 'Category Deleted', 'CATEGORY_DESC' => 'Category Description', 'CATEGORY_DUPLICATE_PARENT' => 'Category cannot be its own parent.', + 'CATEGORY_EMOJI_NOT_ALLOWED' => 'Emoji are not allowed in category names.', 'CATEGORY_HAS_CHILDREN' => 'This category cannot be deleted because it contains children categories.', 'CATEGORY_INFORMATION' => 'Category Information', 'CATEGORY_NAME' => 'Category Name', diff --git a/language/en/contributions.php b/language/en/contributions.php index b704695d3..7fbbed9dc 100644 --- a/language/en/contributions.php +++ b/language/en/contributions.php @@ -72,6 +72,7 @@ 'CONTRIB_DISABLED' => 'Hidden + Disabled', 'CONTRIB_DOWNLOAD_DISABLED' => 'Downloads Disabled', 'CONTRIB_EDITED' => 'The contribution has been successfully edited.', + 'CONTRIB_EMOJI_NOT_ALLOWED' => 'Emoji are not allowed in contribution names, permalinks, authors, links, or other metadata. Emoji may only be used in the contribution description.', 'CONTRIB_HIDDEN' => 'Hidden', 'CONTRIB_ISO_CODE' => 'ISO Code', 'CONTRIB_ISO_CODE_EXPLAIN' => 'The ISO code according to the Translation Coding Guidelines.', @@ -184,6 +185,7 @@ 'REVISION' => 'Revision', 'REVISIONS' => 'Revisions', 'REVISION_APPROVED' => 'Approved', + 'REVISION_EMOJI_NOT_ALLOWED' => 'Emoji are not allowed in revision names, versions, or licenses.', 'REVISION_DENIED' => 'Denied', 'REVISION_FOR_NEXT_PHPBB' => 'This revision has been submitted for the next phpBB release.', 'REVISION_IN_QUEUE' => 'You already have a revision in the validation queue. You must wait until the previous revision is approved or denied to submit a new one.', diff --git a/manage/tool/composer/rebuild_repo.php b/manage/tool/composer/rebuild_repo.php index d27f82d2f..dc468dacb 100644 --- a/manage/tool/composer/rebuild_repo.php +++ b/manage/tool/composer/rebuild_repo.php @@ -19,6 +19,7 @@ use phpbb\titania\config\config as ext_config; use phpbb\titania\contribution\type\collection as type_collection; use phpbb\titania\controller\helper; +use phpbb\titania\emoji; use phpbb\titania\entity\package; use phpbb\titania\ext; use phpbb\titania\manage\tool\base; @@ -357,6 +358,7 @@ protected function get_composer_json($file) if ($path !== null) { $composer_json = file_get_contents($this->package->get_temp_path() . '/' . $path . '/composer.json'); + $composer_json = emoji::escape_json($composer_json); } $this->package->cleanup(); diff --git a/posting.php b/posting.php index 0525b4094..6718d3fed 100644 --- a/posting.php +++ b/posting.php @@ -810,7 +810,7 @@ public function split_topic($topic_id, $mode) if (!$first_post['post_approved']) { $sql = 'UPDATE ' . TITANIA_POSTS_TABLE . ' - SET post_subject = "' . $this->db->sql_escape($subject) . '" + SET post_subject = "' . $this->db->sql_escape(utf8_encode_ucr($subject)) . '" WHERE post_id = ' . (int) $first_post['post_id']; $this->db->sql_query($sql); } diff --git a/url/url.php b/url/url.php index df8fbac38..eca5b20c6 100644 --- a/url/url.php +++ b/url/url.php @@ -192,6 +192,7 @@ public function remove_nth_param($nth) */ public static function generate_slug($string) { + $string = \phpbb\titania\emoji::strip_unsupported($string); $string = self::url_replace($string, false); // Replace any number of spaces with a single underscore From f56ac6f9d05ce30654b4d51abdcd162482410064 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 27 Jul 2026 15:14:04 -0700 Subject: [PATCH 02/11] Code review fixes --- emoji.php | 16 ++++++++++++---- entity/database_base.php | 6 +++--- language/en/common.php | 2 +- language/en/contributions.php | 4 ++-- posting.php | 10 ++++++---- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/emoji.php b/emoji.php index ec70b88d7..1352a5727 100644 --- a/emoji.php +++ b/emoji.php @@ -25,8 +25,8 @@ class emoji */ public static function escape_json($json) { - return preg_replace_callback( - '/[\xF0-\xF4][\x80-\xBF]{3}/', + $escaped_json = preg_replace_callback( + '/[\x{10000}-\x{10FFFF}]/u', function ($matches) { $codepoint = utf8_ord($matches[0]) - 0x10000; @@ -37,11 +37,19 @@ function ($matches) }, $json ); + + if ($escaped_json === null) + { + throw new \UnexpectedValueException('JSON contains invalid UTF-8.'); + } + + return $escaped_json; } /** - * Check whether a string contains emoji or another four-byte character - * unsupported by phpBB's MySQL utf8 schema. + * Check whether a string contains an emoji (including BMP emoji and + * emoji components) or any four-byte character unsupported by phpBB's + * MySQL utf8 schema. * * @param string $value * @return bool diff --git a/entity/database_base.php b/entity/database_base.php index 913630861..f23cf470b 100644 --- a/entity/database_base.php +++ b/entity/database_base.php @@ -255,7 +255,7 @@ protected function validate_string($value, $config) // Truncate to the maximum length if (isset($config['max']) && $config['max']) { - $value = truncate_string($value, $config['max'], PHP_INT_MAX); + $value = truncate_string($value, $config['max']); } if (!empty($config['encode_ucr'])) @@ -266,8 +266,8 @@ protected function validate_string($value, $config) // characters they represent. while (!empty($config['max']) && utf8_strlen($value) > $config['max']) { - $length = utf8_strlen(html_entity_decode($value, ENT_COMPAT)); - $value = truncate_string($value, $length - 1, PHP_INT_MAX); + $length = utf8_strlen(html_entity_decode($value, ENT_COMPAT, 'UTF-8')); + $value = truncate_string($value, $length - 1); $value = utf8_encode_ucr($value); } } diff --git a/language/en/common.php b/language/en/common.php index 30f81ca65..418972ca7 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -63,7 +63,7 @@ 'CATEGORY_DELETED' => 'Category Deleted', 'CATEGORY_DESC' => 'Category Description', 'CATEGORY_DUPLICATE_PARENT' => 'Category cannot be its own parent.', - 'CATEGORY_EMOJI_NOT_ALLOWED' => 'Emoji are not allowed in category names.', + 'CATEGORY_EMOJI_NOT_ALLOWED'=> 'Emojis and other 4-byte characters are not allowed in category names.', 'CATEGORY_HAS_CHILDREN' => 'This category cannot be deleted because it contains children categories.', 'CATEGORY_INFORMATION' => 'Category Information', 'CATEGORY_NAME' => 'Category Name', diff --git a/language/en/contributions.php b/language/en/contributions.php index 7fbbed9dc..6df360d30 100644 --- a/language/en/contributions.php +++ b/language/en/contributions.php @@ -72,7 +72,7 @@ 'CONTRIB_DISABLED' => 'Hidden + Disabled', 'CONTRIB_DOWNLOAD_DISABLED' => 'Downloads Disabled', 'CONTRIB_EDITED' => 'The contribution has been successfully edited.', - 'CONTRIB_EMOJI_NOT_ALLOWED' => 'Emoji are not allowed in contribution names, permalinks, authors, links, or other metadata. Emoji may only be used in the contribution description.', + 'CONTRIB_EMOJI_NOT_ALLOWED' => 'Emojis and other 4-byte characters are not allowed in contribution names, permalinks, authors, links, categories, or other metadata. They may be used in descriptions, posts, topics, comments, reports, and validation output.', 'CONTRIB_HIDDEN' => 'Hidden', 'CONTRIB_ISO_CODE' => 'ISO Code', 'CONTRIB_ISO_CODE_EXPLAIN' => 'The ISO code according to the Translation Coding Guidelines.', @@ -185,7 +185,7 @@ 'REVISION' => 'Revision', 'REVISIONS' => 'Revisions', 'REVISION_APPROVED' => 'Approved', - 'REVISION_EMOJI_NOT_ALLOWED' => 'Emoji are not allowed in revision names, versions, or licenses.', + 'REVISION_EMOJI_NOT_ALLOWED' => 'Emojis and other 4-byte characters are not allowed in revision names, versions, or licenses.', 'REVISION_DENIED' => 'Denied', 'REVISION_FOR_NEXT_PHPBB' => 'This revision has been submitted for the next phpBB release.', 'REVISION_IN_QUEUE' => 'You already have a revision in the validation queue. You must wait until the previous revision is approved or denied to submit a new one.', diff --git a/posting.php b/posting.php index 6718d3fed..2c5f32988 100644 --- a/posting.php +++ b/posting.php @@ -809,10 +809,12 @@ public function split_topic($topic_id, $mode) // Use new subject as the first post's subject to avoid issues when it gets approved if (!$first_post['post_approved']) { - $sql = 'UPDATE ' . TITANIA_POSTS_TABLE . ' - SET post_subject = "' . $this->db->sql_escape(utf8_encode_ucr($subject)) . '" - WHERE post_id = ' . (int) $first_post['post_id']; - $this->db->sql_query($sql); + $first_post_object = new \titania_post($topic->topic_type, $topic, $first_post['post_id']); + if ($first_post_object->load()) + { + $first_post_object->post_subject = $subject; + $first_post_object->update(); + } } } } From cdb7cc35c58abade4e7b79ec89452cb1b628aafd Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 06:41:01 -0700 Subject: [PATCH 03/11] Fix posting issues with emoji to linked forums --- includes/objects/queue.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/includes/objects/queue.php b/includes/objects/queue.php index 89241ea22..01371ea7a 100644 --- a/includes/objects/queue.php +++ b/includes/objects/queue.php @@ -216,13 +216,13 @@ public function update_first_queue_post($post_subject = false) phpbb::$config['min_post_chars'] = 1; phpbb::$config['max_post_chars'] = 0; - $this->forum_queue_update_first_queue_post($post); - // Store the post $post->generate_text_for_storage(true, true, true); $post->submit(); $this->queue_topic_id = $post->topic_id; + + $this->forum_queue_update_first_queue_post($post); } /** @@ -826,8 +826,6 @@ protected function forum_queue_update_first_queue_post(&$post_object) return; } - $post_object->submit(); - titania::_include('functions_posting', 'phpbb_posting'); // Need some stuff @@ -874,10 +872,11 @@ protected function forum_queue_update_first_queue_post(&$post_object) get_formatted_filesize($download['filesize']) ); - $post_text .= "\n\n" . $post_object->post_text; + $queue_post_text = $post_object->post_text; + handle_queue_attachments($post_object, $queue_post_text); + message::decode($queue_post_text, $post_object->post_text_uid); - handle_queue_attachments($post_object, $post_text); - message::decode($post_text, $post_object->post_text_uid); + $post_text .= "\n\n" . $queue_post_text; $post_text .= "\n\n" . $path_helper->strip_url_params($post_object->get_url(), 'sid'); From a08d57480b6685f173b40d73fb91844d90f940a6 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 07:10:44 -0700 Subject: [PATCH 04/11] Detect only BMP emoji, matching the database limitation --- contribution/extension/type.php | 2 +- emoji.php | 13 +++---------- language/en/common.php | 2 +- language/en/contributions.php | 4 ++-- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/contribution/extension/type.php b/contribution/extension/type.php index a8124fd04..171efe31f 100644 --- a/contribution/extension/type.php +++ b/contribution/extension/type.php @@ -217,7 +217,7 @@ public function epv_test(\titania_contribution $contrib, \titania_revision $revi public function validate_ext_name($name) { return (bool) preg_match( - '#^[a-zA-Z0-9\x7f-\xff]{2,}/[a-zA-Z0-9\x7f-\xff]{2,}$#', + '#^[a-zA-Z0-9\x{007F}-\x{FFFF}]{2,}/[a-zA-Z0-9\x{007F}-\x{FFFF}]{2,}$#u', $name ); } diff --git a/emoji.php b/emoji.php index 1352a5727..2d6bf18dc 100644 --- a/emoji.php +++ b/emoji.php @@ -47,9 +47,8 @@ function ($matches) } /** - * Check whether a string contains an emoji (including BMP emoji and - * emoji components) or any four-byte character unsupported by phpBB's - * MySQL utf8 schema. + * Check whether a string contains a four-byte character unsupported by + * phpBB's MySQL utf8 schema. * * @param string $value * @return bool @@ -57,14 +56,8 @@ function ($matches) public static function contains($value) { $value = utf8_decode_ncr($value); - $emoji_pattern = '/[' . - '\x{00A9}\x{00AE}\x{200D}\x{203C}\x{2049}\x{20E3}\x{2122}\x{2139}' . - '\x{2194}-\x{21FF}\x{2300}-\x{23FF}\x{24C2}\x{25AA}-\x{27BF}' . - '\x{2B00}-\x{2BFF}\x{3030}\x{303D}\x{3297}\x{3299}\x{FE0E}\x{FE0F}' . - '\x{10000}-\x{10FFFF}' . - ']/u'; - return (bool) preg_match($emoji_pattern, $value); + return (bool) preg_match('/[\x{10000}-\x{10FFFF}]/u', $value); } /** diff --git a/language/en/common.php b/language/en/common.php index 418972ca7..751cd1c68 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -63,7 +63,7 @@ 'CATEGORY_DELETED' => 'Category Deleted', 'CATEGORY_DESC' => 'Category Description', 'CATEGORY_DUPLICATE_PARENT' => 'Category cannot be its own parent.', - 'CATEGORY_EMOJI_NOT_ALLOWED'=> 'Emojis and other 4-byte characters are not allowed in category names.', + 'CATEGORY_EMOJI_NOT_ALLOWED'=> 'Four-byte characters are not allowed in category names.', 'CATEGORY_HAS_CHILDREN' => 'This category cannot be deleted because it contains children categories.', 'CATEGORY_INFORMATION' => 'Category Information', 'CATEGORY_NAME' => 'Category Name', diff --git a/language/en/contributions.php b/language/en/contributions.php index 6df360d30..2c57fa596 100644 --- a/language/en/contributions.php +++ b/language/en/contributions.php @@ -72,7 +72,7 @@ 'CONTRIB_DISABLED' => 'Hidden + Disabled', 'CONTRIB_DOWNLOAD_DISABLED' => 'Downloads Disabled', 'CONTRIB_EDITED' => 'The contribution has been successfully edited.', - 'CONTRIB_EMOJI_NOT_ALLOWED' => 'Emojis and other 4-byte characters are not allowed in contribution names, permalinks, authors, links, categories, or other metadata. They may be used in descriptions, posts, topics, comments, reports, and validation output.', + 'CONTRIB_EMOJI_NOT_ALLOWED' => 'Four-byte characters are not allowed in contribution names, permalinks, authors, links, categories, or other metadata. They may be used in descriptions, posts, topics, comments, reports, and validation output.', 'CONTRIB_HIDDEN' => 'Hidden', 'CONTRIB_ISO_CODE' => 'ISO Code', 'CONTRIB_ISO_CODE_EXPLAIN' => 'The ISO code according to the Translation Coding Guidelines.', @@ -185,7 +185,7 @@ 'REVISION' => 'Revision', 'REVISIONS' => 'Revisions', 'REVISION_APPROVED' => 'Approved', - 'REVISION_EMOJI_NOT_ALLOWED' => 'Emojis and other 4-byte characters are not allowed in revision names, versions, or licenses.', + 'REVISION_EMOJI_NOT_ALLOWED' => 'Four-byte characters are not allowed in revision names, versions, or licenses.', 'REVISION_DENIED' => 'Denied', 'REVISION_FOR_NEXT_PHPBB' => 'This revision has been submitted for the next phpBB release.', 'REVISION_IN_QUEUE' => 'You already have a revision in the validation queue. You must wait until the previous revision is approved or denied to submit a new one.', From 75f0a6c3f5909ba70f7d5987d0c064c913b62da4 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 07:15:25 -0700 Subject: [PATCH 05/11] Fix permalink issues with 4-byte emoji --- controller/contribution/manage.php | 3 ++- includes/objects/contribution.php | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/controller/contribution/manage.php b/controller/contribution/manage.php index a4ba5f753..50a5fe3d4 100644 --- a/controller/contribution/manage.php +++ b/controller/contribution/manage.php @@ -205,7 +205,8 @@ public function manage($contrib_type, $contrib) $this->settings['categories'], $authors, $this->settings['custom'], - $this->contrib->contrib_name_clean + $this->contrib->contrib_name_clean, + $this->settings['permalink'] )); // Did we succeed or have an error? diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index f000702cf..2d075d1a0 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -1377,7 +1377,6 @@ public function change_status($new_status) public function change_permalink($new_permalink) { $old_permalink = $this->contrib_name_clean; - $new_permalink = url::generate_slug($new_permalink); if ($this->validate_permalink($new_permalink, $old_permalink)) { @@ -1486,10 +1485,11 @@ public function change_permalink($new_permalink) array('active_coauthors' => array(username => username)). * @param array $custom_fields Custom field values. * @param string $old_permalink Old permalink. Defaults to empty string. + * @param string|null $new_permalink Submitted permalink. Defaults to the value on the entity. * * @return array Returns array containing any errors found. */ - public function validate($contrib_categories, $authors, $custom_fields, $old_permalink = '') + public function validate($contrib_categories, $authors, $custom_fields, $old_permalink = '', $new_permalink = null) { phpbb::$user->add_lang('ucp'); @@ -1567,7 +1567,8 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $this->generate_permalink(); } - if (!$metadata_has_emoji && ($permalink_error = $this->validate_permalink($this->contrib_name_clean, $old_permalink)) !== false) + $permalink = $new_permalink !== null ? $new_permalink : $this->contrib_name_clean; + if (!$metadata_has_emoji && ($permalink_error = $this->validate_permalink($permalink, $old_permalink)) !== false) { $error[] = $permalink_error; } From 3ec25798955b831f92eba99ba22a187e4e38fc8f Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 07:26:22 -0700 Subject: [PATCH 06/11] Fixed a few more gaps where four-byte characters could be submitted --- contribution/style/colorizeit_helper.php | 10 ++++++++++ contribution/style/type.php | 3 ++- controller/contribution/revision.php | 17 +++++++++++------ language/en/contributions.php | 2 +- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/contribution/style/colorizeit_helper.php b/contribution/style/colorizeit_helper.php index 6ee21deb5..6c4b0da67 100644 --- a/contribution/style/colorizeit_helper.php +++ b/contribution/style/colorizeit_helper.php @@ -13,6 +13,8 @@ namespace phpbb\titania\contribution\style; +use phpbb\titania\emoji; + class colorizeit_helper { /** @var array */ @@ -69,6 +71,14 @@ public function generate_options($zip_file, $temp_dir) public function submit_options($options, $revision_id, $db) { $options = serialize($options); + + // These values come from configuration files inside the uploaded style. + // Keep serving unsupported names without attempting to cache them. + if (emoji::contains($options)) + { + return; + } + $sql = 'UPDATE ' . TITANIA_REVISIONS_TABLE . ' SET revision_clr_options = "' . $db->sql_escape($options) . '" WHERE revision_id = ' . (int) $revision_id; diff --git a/contribution/style/type.php b/contribution/style/type.php index c151f2f29..c694015a6 100644 --- a/contribution/style/type.php +++ b/contribution/style/type.php @@ -19,6 +19,7 @@ use phpbb\titania\attachment\attachment; use phpbb\titania\config\config as ext_config; use phpbb\titania\contribution\type\base; +use phpbb\titania\emoji; use phpbb\titania\entity\package; use phpbb\user; @@ -165,7 +166,7 @@ public function upload_check(attachment $attachment) public function fix_package_name(\titania_contribution $contrib, \titania_revision $revision, attachment $attachment, $root_dir = null) { // If we managed to find a single parent directory, then we use that in the zip name, otherwise we fall back to using contrib_name_clean - if ($root_dir !== null) + if ($root_dir !== null && !emoji::contains($root_dir)) { $new_real_filename = $root_dir . '_' . strtolower($revision->revision_version) . '.' . $attachment->extension; } diff --git a/controller/contribution/revision.php b/controller/contribution/revision.php index a9a434a7d..67baa5b48 100644 --- a/controller/contribution/revision.php +++ b/controller/contribution/revision.php @@ -616,13 +616,18 @@ protected function validate_settings($settings) $error[] = $this->user->lang['NO_REVISION_VERSION']; } - if ( - emoji::contains($settings['name']) || - emoji::contains($settings['version']) || - emoji::contains($settings['license']) - ) + $metadata = array_merge( + array($settings['name'], $settings['version'], $settings['license']), + $settings['custom'] + ); + + foreach ($metadata as $value) { - $error[] = $this->user->lang['REVISION_EMOJI_NOT_ALLOWED']; + if (is_string($value) && emoji::contains($value)) + { + $error[] = $this->user->lang['REVISION_EMOJI_NOT_ALLOWED']; + break; + } } if (!empty($this->contrib->type->license_options) && !$this->contrib->type->license_allow_custom diff --git a/language/en/contributions.php b/language/en/contributions.php index 2c57fa596..f87284c87 100644 --- a/language/en/contributions.php +++ b/language/en/contributions.php @@ -185,7 +185,7 @@ 'REVISION' => 'Revision', 'REVISIONS' => 'Revisions', 'REVISION_APPROVED' => 'Approved', - 'REVISION_EMOJI_NOT_ALLOWED' => 'Four-byte characters are not allowed in revision names, versions, or licenses.', + 'REVISION_EMOJI_NOT_ALLOWED' => 'Four-byte characters are not allowed in revision names, versions, licenses, or other revision metadata.', 'REVISION_DENIED' => 'Denied', 'REVISION_FOR_NEXT_PHPBB' => 'This revision has been submitted for the next phpBB release.', 'REVISION_IN_QUEUE' => 'You already have a revision in the validation queue. You must wait until the previous revision is approved or denied to submit a new one.', From efb835d2f91d76868dbdb69d70554d9b35a82394 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 08:18:50 -0700 Subject: [PATCH 07/11] Protect perma link characters a little better --- includes/objects/contribution.php | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index 2d075d1a0..1632ce922 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -1668,7 +1668,7 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per */ public function generate_permalink() { - $clean_name = url::generate_slug($this->contrib_name); + $clean_name = $this->generate_permalink_slug($this->contrib_name); $append = ''; $i = 2; while ($this->permalink_exists($clean_name . $append)) @@ -1679,6 +1679,17 @@ public function generate_permalink() $this->contrib_name_clean = $clean_name . $append; } + /** + * Generate an ASCII-only contribution permalink. + * + * @param string $value + * @return string + */ + protected function generate_permalink_slug($value) + { + return preg_replace('/[^a-z0-9_]+/', '_', url::generate_slug($value)); + } + /* * Validate a contrib permalink * @@ -1689,9 +1700,16 @@ public function generate_permalink() */ public function validate_permalink($permalink, $old_permalink) { - if (url::generate_slug($permalink) !== $permalink) + // Preserve existing Unicode permalinks until they are intentionally changed. + if ($permalink !== '' && $permalink === $old_permalink) + { + return false; + } + + $generated_permalink = $this->generate_permalink_slug($permalink); + if ($generated_permalink !== $permalink) { - return phpbb::$user->lang('INVALID_PERMALINK', url::generate_slug($permalink)); + return phpbb::$user->lang('INVALID_PERMALINK', $generated_permalink); } if ($permalink === '' || ($permalink !== $old_permalink && $this->permalink_exists($permalink))) From e47f059e8fd84da4ed14377ebba0eada3b5b9c56 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 08:22:54 -0700 Subject: [PATCH 08/11] Rename emoji class to unicode class --- attachment/uploader.php | 4 ++-- contribution/extension/type.php | 4 ++-- contribution/style/colorizeit_helper.php | 4 ++-- contribution/style/type.php | 4 ++-- controller/contribution/revision.php | 4 ++-- controller/contribution/revision_edit.php | 8 ++++---- includes/objects/category.php | 4 ++-- includes/objects/contribution.php | 6 +++--- manage/tool/composer/rebuild_repo.php | 4 ++-- emoji.php => unicode.php | 4 ++-- url/url.php | 2 +- 11 files changed, 24 insertions(+), 24 deletions(-) rename emoji.php => unicode.php (96%) diff --git a/attachment/uploader.php b/attachment/uploader.php index 4ed527239..067e8f72e 100644 --- a/attachment/uploader.php +++ b/attachment/uploader.php @@ -16,7 +16,7 @@ use phpbb\files\upload; use phpbb\request\request_interface; use phpbb\titania\access; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\ext; class uploader @@ -261,7 +261,7 @@ public function upload_file() return false; } - if (emoji::contains($file->get('uploadname'))) + if (unicode::contains_unsupported($file->get('uploadname'))) { $file->error[] = $this->user->lang('INVALID_FILENAME', $file->get('uploadname')); $file->remove(); diff --git a/contribution/extension/type.php b/contribution/extension/type.php index 171efe31f..7e8eb2040 100644 --- a/contribution/extension/type.php +++ b/contribution/extension/type.php @@ -18,7 +18,7 @@ use phpbb\titania\attachment\attachment; use phpbb\titania\config\config as ext_config; use phpbb\titania\contribution\type\base; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\entity\package; use phpbb\user; @@ -284,7 +284,7 @@ protected function repack(package $package, \titania_contribution $contrib, \tit $data = $this->update_phpbb_requirement($data, $revision); $data = $this->set_version_check($data, $contrib); - $data = emoji::escape_json(json_encode( + $data = unicode::escape_json(json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )); diff --git a/contribution/style/colorizeit_helper.php b/contribution/style/colorizeit_helper.php index 6c4b0da67..6bef945eb 100644 --- a/contribution/style/colorizeit_helper.php +++ b/contribution/style/colorizeit_helper.php @@ -13,7 +13,7 @@ namespace phpbb\titania\contribution\style; -use phpbb\titania\emoji; +use phpbb\titania\unicode; class colorizeit_helper { @@ -74,7 +74,7 @@ public function submit_options($options, $revision_id, $db) // These values come from configuration files inside the uploaded style. // Keep serving unsupported names without attempting to cache them. - if (emoji::contains($options)) + if (unicode::contains_unsupported($options)) { return; } diff --git a/contribution/style/type.php b/contribution/style/type.php index c694015a6..015cc2482 100644 --- a/contribution/style/type.php +++ b/contribution/style/type.php @@ -19,7 +19,7 @@ use phpbb\titania\attachment\attachment; use phpbb\titania\config\config as ext_config; use phpbb\titania\contribution\type\base; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\entity\package; use phpbb\user; @@ -166,7 +166,7 @@ public function upload_check(attachment $attachment) public function fix_package_name(\titania_contribution $contrib, \titania_revision $revision, attachment $attachment, $root_dir = null) { // If we managed to find a single parent directory, then we use that in the zip name, otherwise we fall back to using contrib_name_clean - if ($root_dir !== null && !emoji::contains($root_dir)) + if ($root_dir !== null && !unicode::contains_unsupported($root_dir)) { $new_real_filename = $root_dir . '_' . strtolower($revision->revision_version) . '.' . $attachment->extension; } diff --git a/controller/contribution/revision.php b/controller/contribution/revision.php index 67baa5b48..72a8bb7ea 100644 --- a/controller/contribution/revision.php +++ b/controller/contribution/revision.php @@ -15,7 +15,7 @@ use phpbb\titania\composer\repository; use phpbb\titania\contribution\type\collection as type_collection; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\ext; use Symfony\Component\HttpFoundation\JsonResponse; @@ -623,7 +623,7 @@ protected function validate_settings($settings) foreach ($metadata as $value) { - if (is_string($value) && emoji::contains($value)) + if (is_string($value) && unicode::contains_unsupported($value)) { $error[] = $this->user->lang['REVISION_EMOJI_NOT_ALLOWED']; break; diff --git a/controller/contribution/revision_edit.php b/controller/contribution/revision_edit.php index c8d678500..d4b53a42a 100644 --- a/controller/contribution/revision_edit.php +++ b/controller/contribution/revision_edit.php @@ -14,7 +14,7 @@ namespace phpbb\titania\controller\contribution; use phpbb\titania\contribution\type\collection as type_collection; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\ext; use Symfony\Component\HttpFoundation\JsonResponse; @@ -246,9 +246,9 @@ protected function validate_settings($settings) } if ( - emoji::contains($settings['name']) || - emoji::contains($settings['license']) || - emoji::contains($settings['custom_license']) + unicode::contains_unsupported($settings['name']) || + unicode::contains_unsupported($settings['license']) || + unicode::contains_unsupported($settings['custom_license']) ) { $error[] = $this->user->lang['REVISION_EMOJI_NOT_ALLOWED']; diff --git a/includes/objects/category.php b/includes/objects/category.php index 2d2ca9668..aae5372b3 100644 --- a/includes/objects/category.php +++ b/includes/objects/category.php @@ -11,7 +11,7 @@ * */ -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\ext; use phpbb\titania\sync; @@ -703,7 +703,7 @@ public function validate() { $error[] = phpbb::$user->lang['NO_CATEGORY_NAME']; } - else if (emoji::contains($this->category_name)) + else if (unicode::contains_unsupported($this->category_name)) { $error[] = phpbb::$user->lang['CATEGORY_EMOJI_NOT_ALLOWED']; } diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index 1632ce922..c644e1036 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -15,7 +15,7 @@ use phpbb\titania\composer\repository; use phpbb\titania\contribution\type\collection as type_collection; use phpbb\titania\contribution\type\type_interface; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\ext; use phpbb\titania\message\message; use phpbb\titania\url\url; @@ -1520,7 +1520,7 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per foreach ($metadata as $value) { - if (is_string($value) && emoji::contains($value)) + if (is_string($value) && unicode::contains_unsupported($value)) { $metadata_has_emoji = true; $error[] = phpbb::$user->lang['CONTRIB_EMOJI_NOT_ALLOWED']; @@ -1760,7 +1760,7 @@ public function get_authors_from_usernames($authors) foreach (explode("\n", $users) as $username) { - if (emoji::contains($username)) + if (unicode::contains_unsupported($username)) { $result['emoji'] = true; continue; diff --git a/manage/tool/composer/rebuild_repo.php b/manage/tool/composer/rebuild_repo.php index dc468dacb..b046e9c4e 100644 --- a/manage/tool/composer/rebuild_repo.php +++ b/manage/tool/composer/rebuild_repo.php @@ -19,7 +19,7 @@ use phpbb\titania\config\config as ext_config; use phpbb\titania\contribution\type\collection as type_collection; use phpbb\titania\controller\helper; -use phpbb\titania\emoji; +use phpbb\titania\unicode; use phpbb\titania\entity\package; use phpbb\titania\ext; use phpbb\titania\manage\tool\base; @@ -358,7 +358,7 @@ protected function get_composer_json($file) if ($path !== null) { $composer_json = file_get_contents($this->package->get_temp_path() . '/' . $path . '/composer.json'); - $composer_json = emoji::escape_json($composer_json); + $composer_json = unicode::escape_json($composer_json); } $this->package->cleanup(); diff --git a/emoji.php b/unicode.php similarity index 96% rename from emoji.php rename to unicode.php index 2d6bf18dc..ef16d1504 100644 --- a/emoji.php +++ b/unicode.php @@ -13,7 +13,7 @@ namespace phpbb\titania; -class emoji +class unicode { /** * Escape four-byte UTF-8 characters as JSON surrogate pairs. @@ -53,7 +53,7 @@ function ($matches) * @param string $value * @return bool */ - public static function contains($value) + public static function contains_unsupported($value) { $value = utf8_decode_ncr($value); diff --git a/url/url.php b/url/url.php index eca5b20c6..55811a400 100644 --- a/url/url.php +++ b/url/url.php @@ -192,7 +192,7 @@ public function remove_nth_param($nth) */ public static function generate_slug($string) { - $string = \phpbb\titania\emoji::strip_unsupported($string); + $string = \phpbb\titania\unicode::strip_unsupported($string); $string = self::url_replace($string, false); // Replace any number of spaces with a single underscore From 7c58624b162aa2a4d2da6cee8185dc95eaf4fb75 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 18:56:14 -0700 Subject: [PATCH 09/11] Improve filtering of perma link characters --- includes/objects/contribution.php | 33 +++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index c644e1036..b51894d0c 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -1561,13 +1561,13 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $this->set_type($this->contrib_type); $error = array_merge($error, $this->type->validate_contrib_fields($custom_fields)); - if (!$metadata_has_emoji && !$this->contrib_name_clean) + $permalink = $new_permalink !== null ? $new_permalink : $this->contrib_name_clean; + if (!$metadata_has_emoji && $permalink === '') { // If they leave it blank automatically create it - $this->generate_permalink(); + $permalink = $this->find_available_permalink($this->contrib_name); } - $permalink = $new_permalink !== null ? $new_permalink : $this->contrib_name_clean; if (!$metadata_has_emoji && ($permalink_error = $this->validate_permalink($permalink, $old_permalink)) !== false) { $error[] = $permalink_error; @@ -1668,7 +1668,18 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per */ public function generate_permalink() { - $clean_name = $this->generate_permalink_slug($this->contrib_name); + $this->contrib_name_clean = $this->find_available_permalink($this->contrib_name); + } + + /** + * Generate an available contribution permalink. + * + * @param string $value + * @return string + */ + protected function find_available_permalink($value) + { + $clean_name = $this->generate_permalink_slug($value); $append = ''; $i = 2; while ($this->permalink_exists($clean_name . $append)) @@ -1676,18 +1687,23 @@ public function generate_permalink() $append = '_' . $i; $i++; } - $this->contrib_name_clean = $clean_name . $append; + + return $clean_name . $append; } /** - * Generate an ASCII-only contribution permalink. + * Generate a contribution permalink containing Unicode letters, numbers, + * and underscores. * * @param string $value * @return string */ protected function generate_permalink_slug($value) { - return preg_replace('/[^a-z0-9_]+/', '_', url::generate_slug($value)); + $permalink = preg_replace('/[^\p{L}\p{N}_]+/u', '_', url::generate_slug($value)); + $permalink = preg_replace('/_+/', '_', $permalink); + + return trim($permalink, '_'); } /* @@ -1731,7 +1747,8 @@ public function permalink_exists($permalink) $sql = 'SELECT contrib_id FROM ' . $this->sql_table . " WHERE contrib_name_clean = '" . phpbb::$db->sql_escape($permalink) . "' - AND contrib_type = " . (int) $this->contrib_type; + AND contrib_type = " . (int) $this->contrib_type . ' + AND contrib_id <> ' . (int) $this->contrib_id; $result = phpbb::$db->sql_query($sql); $contrib_id = phpbb::$db->sql_fetchfield('contrib_id'); phpbb::$db->sql_freeresult($result); From 2b29017c455e49d5dfb16ce011866fb8c29330f4 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 28 Jul 2026 19:10:43 -0700 Subject: [PATCH 10/11] Reword permalink error messages --- includes/objects/contribution.php | 9 +++++++-- language/en/contributions.php | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index b51894d0c..01c6eeee8 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -1728,9 +1728,14 @@ public function validate_permalink($permalink, $old_permalink) return phpbb::$user->lang('INVALID_PERMALINK', $generated_permalink); } - if ($permalink === '' || ($permalink !== $old_permalink && $this->permalink_exists($permalink))) + if ($permalink === '') { - return phpbb::$user->lang['CONTRIB_NAME_EXISTS']; + return phpbb::$user->lang['EMPTY_CONTRIB_PERMALINK']; + } + + if ($permalink !== $old_permalink && $this->permalink_exists($permalink)) + { + return phpbb::$user->lang['CONTRIB_PERMALINK_EXISTS']; } return false; diff --git a/language/en/contributions.php b/language/en/contributions.php index f87284c87..69d3aa9e7 100644 --- a/language/en/contributions.php +++ b/language/en/contributions.php @@ -79,13 +79,13 @@ 'CONTRIB_LOCAL_NAME' => 'Local name', 'CONTRIB_LOCAL_NAME_EXPLAIN' => 'The localized name of the language, e.g. Français.', 'CONTRIB_NAME' => 'Contribution Name', - 'CONTRIB_NAME_EXISTS' => 'The unique name has already been reserved.', 'CONTRIB_NEW' => 'New', 'CONTRIB_NONACTIVE_AUTHORS' => 'Non-Active Co-Authors (Past Contributors)', 'CONTRIB_NONACTIVE_AUTHORS_EXPLAIN' => 'Non-Active Co-Authors can not manage anything for the contribution and are only listed as previous authors.', 'CONTRIB_NOT_FOUND' => 'The contribution you requested could not be found.', 'CONTRIB_OWNER_UPDATED' => 'The owner has been changed.', 'CONTRIB_PERMALINK' => 'Contribution Permalink', + 'CONTRIB_PERMALINK_EXISTS' => 'That contribution permalink is already in use.', 'CONTRIB_PERMALINK_EXPLAIN' => 'Cleaned version of the contribution name, used to build the url for the contribution.
Leave blank to have one automatically created based on the contribution name.', 'CONTRIB_RELEASE_DATE' => 'Release date', 'CONTRIB_STATUS' => 'Contribution status', @@ -118,7 +118,7 @@ 'EMPTY_CONTRIB_ISO_CODE' => 'Enter the ISO Code', 'EMPTY_CONTRIB_LOCAL_NAME' => 'Enter the local name', 'EMPTY_CONTRIB_NAME' => 'Enter the contrib name', - 'EMPTY_CONTRIB_PERMALINK' => 'Enter your proposal for permalink for the contribution', + 'EMPTY_CONTRIB_PERMALINK' => 'A valid contribution permalink could not be generated. Please enter one manually.', 'EMPTY_CONTRIB_TYPE' => 'Select at least one contribution type', 'ERROR_CONTRIB_EMAIL_FRIEND' => 'You are not permitted to recommend this contribution to someone else.', From 82ca9fbaf5c8a98c62ab674d23c42d79cdcdaafb Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Wed, 29 Jul 2026 06:55:24 -0700 Subject: [PATCH 11/11] Fix regressions --- controller/author.php | 5 ++ controller/contribution/manage.php | 14 ++++-- includes/objects/contribution.php | 78 ++++++++++-------------------- unicode.php | 8 ++- 4 files changed, 46 insertions(+), 59 deletions(-) diff --git a/controller/author.php b/controller/author.php index a8d0267d5..9219a5010 100644 --- a/controller/author.php +++ b/controller/author.php @@ -405,6 +405,11 @@ protected function create() )); $authors['author'] = array($this->user->data['username'] => $this->user->data['user_id']); + if ($contrib->contrib_name_clean === '') + { + $contrib->generate_permalink(); + } + $error = $contrib->validate($settings['categories'], $authors, $settings['custom']); if (($form_key_error = $message->validate_form_key()) !== false) diff --git a/controller/contribution/manage.php b/controller/contribution/manage.php index 50a5fe3d4..5ca73ffac 100644 --- a/controller/contribution/manage.php +++ b/controller/contribution/manage.php @@ -193,6 +193,15 @@ public function manage($contrib_type, $contrib) $this->contrib->post_data($this->message); + if (!$this->is_moderator) + { + $this->settings['permalink'] = $this->contrib->contrib_name_clean; + } + else if ($this->settings['permalink'] === '') + { + $this->settings['permalink'] = $this->contrib->get_generated_permalink(); + } + $authors = $this->contrib->get_authors_from_usernames(array( 'active_coauthors' => $this->settings['coauthors']['active'], 'nonactive_coauthors' => $this->settings['coauthors']['nonactive'], @@ -474,11 +483,6 @@ protected function submit($authors, $old_settings) if ($this->settings['permalink'] != $this->contrib->contrib_name_clean) { - if ($this->settings['permalink'] == '') - { - $this->contrib->generate_permalink(); - $this->settings['permalink'] = $this->contrib->contrib_name_clean; - } $this->contrib->change_permalink($this->settings['permalink']); } } diff --git a/includes/objects/contribution.php b/includes/objects/contribution.php index 01c6eeee8..66f98acd1 100644 --- a/includes/objects/contribution.php +++ b/includes/objects/contribution.php @@ -1500,29 +1500,27 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $error[] = phpbb::$user->lang['EMPTY_CONTRIB_NAME']; } - $metadata = $this->__get_array(); - unset( - $metadata['contrib_desc'], - $metadata['contrib_desc_bitfield'], - $metadata['contrib_desc_uid'], - $metadata['contrib_desc_options'] + $metadata = array( + $this->contrib_name, + $new_permalink !== null ? $new_permalink : $this->contrib_name_clean, ); $metadata = array_merge($metadata, $custom_fields); $demos = json_decode($this->contrib_demo, true); if (is_array($demos)) { - // JSON encoding can otherwise hide emoji behind surrogate escapes. + // JSON encoding can otherwise hide unsupported characters behind + // surrogate escapes. $metadata['contrib_demo'] = implode("\n", $demos); } - $metadata_has_emoji = false; + $metadata_has_unsupported = false; foreach ($metadata as $value) { - if (is_string($value) && unicode::contains_unsupported($value)) + if (is_string($value) && unicode::contains_unsupported($value, false)) { - $metadata_has_emoji = true; + $metadata_has_unsupported = true; $error[] = phpbb::$user->lang['CONTRIB_EMOJI_NOT_ALLOWED']; break; } @@ -1562,13 +1560,7 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $error = array_merge($error, $this->type->validate_contrib_fields($custom_fields)); $permalink = $new_permalink !== null ? $new_permalink : $this->contrib_name_clean; - if (!$metadata_has_emoji && $permalink === '') - { - // If they leave it blank automatically create it - $permalink = $this->find_available_permalink($this->contrib_name); - } - - if (!$metadata_has_emoji && ($permalink_error = $this->validate_permalink($permalink, $old_permalink)) !== false) + if (!$metadata_has_unsupported && ($permalink_error = $this->validate_permalink($permalink, $old_permalink)) !== false) { $error[] = $permalink_error; } @@ -1618,11 +1610,6 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per $author = key($authors['author']); $missing_coauthors = array_merge($authors['missing']['active_coauthors'], $authors['missing']['nonactive_coauthors']); - if (!empty($authors['emoji']) && !$metadata_has_emoji) - { - $error[] = phpbb::$user->lang['CONTRIB_EMOJI_NOT_ALLOWED']; - } - if (!empty($missing_coauthors)) { $error[] = phpbb::$user->lang('COULD_NOT_FIND_USERS', phpbb_generate_string_list($missing_coauthors, phpbb::$user)); @@ -1668,18 +1655,17 @@ public function validate($contrib_categories, $authors, $custom_fields, $old_per */ public function generate_permalink() { - $this->contrib_name_clean = $this->find_available_permalink($this->contrib_name); + $this->contrib_name_clean = $this->get_generated_permalink(); } /** * Generate an available contribution permalink. * - * @param string $value * @return string */ - protected function find_available_permalink($value) + public function get_generated_permalink() { - $clean_name = $this->generate_permalink_slug($value); + $clean_name = $this->generate_permalink_slug($this->contrib_name); $append = ''; $i = 2; while ($this->permalink_exists($clean_name . $append)) @@ -1692,18 +1678,22 @@ protected function find_available_permalink($value) } /** - * Generate a contribution permalink containing Unicode letters, numbers, - * and underscores. + * Generate a contribution permalink containing Unicode letters, combining + * marks, numbers, and underscores. * * @param string $value * @return string */ protected function generate_permalink_slug($value) { - $permalink = preg_replace('/[^\p{L}\p{N}_]+/u', '_', url::generate_slug($value)); - $permalink = preg_replace('/_+/', '_', $permalink); - - return trim($permalink, '_'); + // Variation selectors control the presentation of the preceding + // character. They are combining marks, but have no place in a + // permalink and can otherwise survive after an emoji is removed. + $value = preg_replace('/[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}]/u', '', $value); + + // Preserve repeated and surrounding underscores for compatibility with + // existing permalink rules. + return preg_replace('/[^\p{L}\p{M}\p{N}_]+/u', '_', url::generate_slug($value)); } /* @@ -1771,29 +1761,13 @@ public function permalink_exists($permalink) */ public function get_authors_from_usernames($authors) { - $result = array( - 'missing' => array(), - 'emoji' => false, - ); + $result = array('missing' => array()); foreach ($authors as $group => $users) { - $valid_users = array(); - - foreach (explode("\n", $users) as $username) - { - if (unicode::contains_unsupported($username)) - { - $result['emoji'] = true; - continue; - } - - $valid_users[] = $username; - } - - $user_data = user_helper::get_user_ids_from_list($this->db, implode("\n", $valid_users)); - $result[$group] = $user_data['ids']; - $result['missing'][$group] = $user_data['missing']; + $users = user_helper::get_user_ids_from_list($this->db, $users); + $result[$group] = $users['ids']; + $result['missing'][$group] = $users['missing']; } return $result; diff --git a/unicode.php b/unicode.php index ef16d1504..e984dfe26 100644 --- a/unicode.php +++ b/unicode.php @@ -51,11 +51,15 @@ function ($matches) * phpBB's MySQL utf8 schema. * * @param string $value + * @param bool $decode_ncr Whether to decode numeric character references. * @return bool */ - public static function contains_unsupported($value) + public static function contains_unsupported($value, $decode_ncr = true) { - $value = utf8_decode_ncr($value); + if ($decode_ncr) + { + $value = utf8_decode_ncr($value); + } return (bool) preg_match('/[\x{10000}-\x{10FFFF}]/u', $value); }