From 37f3a4c4300e999f1048005f9c39fb2c52f65fca Mon Sep 17 00:00:00 2001
From: Sohom <sohomdatta1+git@gmail.com>
Date: Wed, 23 Aug 2023 22:18:50 +0530
Subject: [PATCH] SECURITY: Don't expose usernames if user is hidden

- Remove user_name and user info from 'pagetriagelist' API
- Add user_hidden field to 'pagetriagelist' API
- Modify pagetriage toolbar to handle cases where no username
is provided
- Modify Special:NewPagesFeed to gracefully handle cases where
the username is hidden
- Add a special case in the Special:NewPagesFeed vue version
for hidden usernames (where other information is availiable)

Bug: T344359
Change-Id: I5714f69a70909e3c3e7322e5f3be62d837e4d1cc
---
 extension.json                                | 13 ++++++-
 i18n/en.json                                  |  1 +
 i18n/qqq.json                                 |  1 +
 includes/Api/ApiPageTriageList.php            | 37 +++++++++++++++----
 includes/Hooks.php                            |  1 +
 includes/SpecialNewPagesFeed.php              |  3 ++
 .../components/ListContent.vue                |  1 +
 .../components/ListItem.vue                   |  9 ++++-
 .../models/ext.pageTriage.article.js          |  3 +-
 .../ext.pageTriage.listItem.underscore        |  8 +++-
 .../ext.pageTriage.views.toolbar/ToolView.js  |  1 +
 .../articleInfo.css                           |  9 -----
 .../articleInfo.js                            | 10 ++++-
 .../articleInfoHistory.underscore             | 14 ++++---
 modules/ext.pageTriage.views.toolbar/mark.js  |  3 +-
 modules/ext.pageTriage.views.toolbar/tags.js  |  2 +-
 .../ext.pageTriage.views.toolbar/wikiLove.js  |  7 +++-
 17 files changed, 92 insertions(+), 31 deletions(-)

diff --git a/extension.json b/extension.json
index 3895ce32..30c9fae2 100644
--- a/extension.json
+++ b/extension.json
@@ -15,7 +15,12 @@
 		"MediaWiki": ">= 1.41"
 	},
 	"APIModules": {
-		"pagetriagelist": "MediaWiki\\Extension\\PageTriage\\Api\\ApiPageTriageList",
+		"pagetriagelist": {
+			"class": "MediaWiki\\Extension\\PageTriage\\Api\\ApiPageTriageList",
+			"services": [
+				"UserFactory"
+			]
+		},
 		"pagetriagestats": "MediaWiki\\Extension\\PageTriage\\Api\\ApiPageTriageStats",
 		"pagetriageaction": {
 			"class": "MediaWiki\\Extension\\PageTriage\\Api\\ApiPageTriageAction",
@@ -239,8 +244,10 @@
 				"pagetriage-mark-as-unreviewed",
 				"pagetriage-info-title",
 				"pagetriage-byline",
+				"rev-deleted-user",
 				"pagetriage-byline-new-editor",
 				"pagetriage-articleinfo-byline",
+				"pagetriage-articleinfo-byline-hidden-username",
 				"pagetriage-articleinfo-byline-new-editor",
 				"pipe-separator",
 				"pagetriage-edits",
@@ -390,6 +397,8 @@
 				"pagetriage-recreated",
 				"pagetriage-no-author",
 				"pagetriage-byline",
+				"pagetriage-byline-hidden-username",
+				"rev-deleted-user",
 				"pagetriage-byline-new-editor",
 				"pagetriage-editcount",
 				"pagetriage-author-not-autoconfirmed",
@@ -587,6 +596,7 @@
 				"pagetriage-no-author",
 				"pagetriage-no-pages",
 				"pagetriage-byline",
+				"pagetriage-byline-hidden-username",
 				"pagetriage-byline-heading",
 				"pagetriage-byline-new-editor",
 				"pagetriage-byline-new-editor-heading",
@@ -602,6 +612,7 @@
 				"pagetriage-unreviewed-article-count",
 				"pagetriage-reviewed-article-count-past-week",
 				"pagetriage-unreviewed-draft-count",
+				"rev-deleted-user",
 				"pagetriage-sort-by",
 				"pagetriage-newest",
 				"pagetriage-oldest",
diff --git a/i18n/en.json b/i18n/en.json
index edfd73f8..263238f2 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -37,6 +37,7 @@
 	"pagetriage-byline-new-editor": "Created by new editor $1 ($2$3$4)",
 	"pagetriage-byline-new-editor-heading": "Created by a {{GENDER:$1|new editor}}",
 	"pagetriage-articleinfo-byline": "This page was created on $1 by $2 ($3$4$5)",
+	"pagetriage-articleinfo-byline-hidden-username": "This page was created on $1",
 	"pagetriage-articleinfo-byline-new-editor": "This page was created on $1 by new editor $2 ($3$4$5)",
 	"pagetriage-editcount": "$1 {{PLURAL:$1|edit|edits}} since $2",
 	"pagetriage-author-not-autoconfirmed": "New editor",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 7e4b3415..2e02d690 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -57,6 +57,7 @@
 	"pagetriage-byline-new-editor": "Text indicating the page author (for when the author is a new editor). $1 is a link to the author's user page, $2 is a link to the author's talk page, $3 is a separator character, $4 is a link to the author's contributions.",
 	"pagetriage-byline-new-editor-heading": "Text indicating the page author (for when the author is a new editor). Parameters:\n* $1 - the 'author's username, used for GENDER support.",
 	"pagetriage-articleinfo-byline": "Text indicating the page author. $1 is the article creation date, $2 is a link to the author's user page, $3 is a link to the author's talk page, $4 is a separator character. $5 is a link to the author's contributions.",
+	"pagetriage-articleinfo-byline-hidden-username": "Text indicating that a article was created by a author whoes name has been removed in the article info section of the pagetriage toolbar. $1 is the article creation date",
 	"pagetriage-articleinfo-byline-new-editor": "Text indicating the page author (for when the author is a new editor). Parameters:\n* $1 is the article creation date.\n* $2 is a link to the author's user page.\n* $3 is a link to the author's talk page.\n* $4 is a separator character\n* $5 is a link to the author's contributions.",
 	"pagetriage-editcount": "Display of page author's editing experience. $1 is total edit count, $2 is author's join date",
 	"pagetriage-author-not-autoconfirmed": "String indicating that the author was not yet autoconfirmed when the page was last edited",
diff --git a/includes/Api/ApiPageTriageList.php b/includes/Api/ApiPageTriageList.php
index a1e25ada..f6382cd1 100644
--- a/includes/Api/ApiPageTriageList.php
+++ b/includes/Api/ApiPageTriageList.php
@@ -3,12 +3,14 @@
 namespace MediaWiki\Extension\PageTriage\Api;
 
 use ApiBase;
+use ApiMain;
 use ApiResult;
 use MediaWiki\Extension\PageTriage\ArticleMetadata;
 use MediaWiki\Extension\PageTriage\OresMetadata;
 use MediaWiki\Extension\PageTriage\PageTriageUtil;
 use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\Title\Title;
+use MediaWiki\User\UserFactory;
 use ORES\Services\ORESServices;
 use SpecialPage;
 use Wikimedia\ParamValidator\ParamValidator;
@@ -22,6 +24,18 @@ use Wikimedia\ParamValidator\TypeDef\IntegerDef;
  */
 class ApiPageTriageList extends ApiBase {
 
+	/** @var UserFactory */
+	private UserFactory $userFactory;
+
+	/**
+	 * @param ApiMain $query
+	 * @param string $moduleName
+	 */
+	public function __construct( ApiMain $query, string $moduleName, UserFactory $userFactory ) {
+		$this->userFactory = $userFactory;
+		parent::__construct( $query, $moduleName );
+	}
+
 	public function execute() {
 		// Get the API parameters and store them
 		$opts = $this->extractRequestParams();
@@ -72,12 +86,20 @@ class ApiPageTriageList extends ApiBase {
 					$metaData[$page]['creation_date']
 				);
 
-				// Page creator
-				$metaData[$page] += $this->createUserInfo(
-					$metaData[$page]['user_name'],
-					$userPageStatus,
-					'creator'
-				);
+				if ( $metaData[$page]['user_name'] ) {
+					// Page creator
+					$user = $this->userFactory->newFromName( $metaData[$page]['user_name'] );
+					if ( $user && $user->isHidden() ) {
+						$metaData[$page]['user_name'] = null;
+						$metaData[$page]['creator_hidden'] = true;
+					} else {
+						$metaData[$page] += $this->createUserInfo(
+							$metaData[$page]['user_name'],
+							$userPageStatus,
+							'creator'
+						);
+					}
+				}
 
 				// Page reviewer
 				if ( $metaData[$page]['reviewer'] ) {
@@ -103,7 +125,7 @@ class ApiPageTriageList extends ApiBase {
 				}
 
 				$metaData[$page][ApiResult::META_BC_BOOLS] = [
-					'creator_user_page_exist', 'creator_user_talk_page_exist',
+					'creator_hidden', 'creator_user_page_exist', 'creator_user_talk_page_exist',
 					'reviewer_user_page_exist', 'reviewer_user_talk_page_exist',
 				];
 
@@ -219,6 +241,7 @@ class ApiPageTriageList extends ApiBase {
 			$prefix . '_user_talk_page_exist' => isset( $userPageStatus[$userTalkPage->getPrefixedDBkey()] ),
 			$prefix . '_contribution_page' => $userContribsPage->getPrefixedText(),
 			$prefix . '_contribution_page_url' => $userContribsPage->getFullURL(),
+			$prefix . '_hidden' => false,
 		];
 	}
 
diff --git a/includes/Hooks.php b/includes/Hooks.php
index 416e6d41..637f8e24 100644
--- a/includes/Hooks.php
+++ b/includes/Hooks.php
@@ -506,6 +506,7 @@ class Hooks implements
 				$request->getVal( 'curationtoolbar' ) === 'true' ) {
 				// Load the JavaScript for the curation toolbar
 				$outputPage->addModules( 'ext.pageTriage.toolbarStartup' );
+				$outputPage->addModuleStyles( [ 'mediawiki.interface.helpers.styles' ] );
 			} else {
 				if ( $needsReview ) {
 					// show 'Mark as reviewed' link
diff --git a/includes/SpecialNewPagesFeed.php b/includes/SpecialNewPagesFeed.php
index 4e2f16ab..bb084d2b 100644
--- a/includes/SpecialNewPagesFeed.php
+++ b/includes/SpecialNewPagesFeed.php
@@ -57,6 +57,9 @@ class SpecialNewPagesFeed extends SpecialPage {
 		// Output the title of the page
 		$out->setPageTitleMsg( $this->msg( 'newpagesfeed' ) );
 
+		// Load common interface css
+		$out->addModuleStyles( [ 'mediawiki.interface.helpers.styles' ] );
+
 		// Allow infinite scrolling override from query string parameter
 		// We don't use getBool() here since the param is optional
 		if ( $request->getText( 'infinite' ) === 'true' ) {
diff --git a/modules/ext.pageTriage.list/components/ListContent.vue b/modules/ext.pageTriage.list/components/ListContent.vue
index b01fc33c..bb682a66 100644
--- a/modules/ext.pageTriage.list/components/ListContent.vue
+++ b/modules/ext.pageTriage.list/components/ListContent.vue
@@ -58,6 +58,7 @@ const listItemPropFormatter = ( pageInfo ) => {
 	listItemProps.revCount = parseInt( pageInfo.rev_count );
 	listItemProps.creationDateUTC = pageInfo.creation_date_utc;
 	listItemProps.creatorName = pageInfo.user_name;
+	listItemProps.creatorHidden = pageInfo.creator_hidden;
 	listItemProps.creatorAutoConfirmed = pageInfo.user_autoconfirmed === '1';
 	listItemProps.creatorRegistrationUTC = pageInfo.user_creation_date;
 	listItemProps.creatorUserId = parseInt( pageInfo.user_id );
diff --git a/modules/ext.pageTriage.list/components/ListItem.vue b/modules/ext.pageTriage.list/components/ListItem.vue
index 2a42cd54..df895f1b 100644
--- a/modules/ext.pageTriage.list/components/ListItem.vue
+++ b/modules/ext.pageTriage.list/components/ListItem.vue
@@ -58,9 +58,15 @@
 			</div>
 			<div class="mwe-vue-pt-info-row">
 				<div>
-					<span v-if="creatorName">
+					<!-- if the username is suppressed, present it the same way as in core changelists -->
+					<span v-if="creatorHidden" class="history-deleted mw-history-suppressed mw-userlink">
+						{{ $i18n( 'rev-deleted-user' ).text() }}
+					</span>
+					<span v-else-if="creatorName">
 						<creator-byline
+							v-if="creatorName"
 							:creator-name="creatorName"
+							:creator-hidden="creatorHidden"
 							:creator-user-id="creatorUserId"
 							:creator-auto-confirmed="creatorAutoConfirmed"
 							:creator-user-page-exists="creatorUserPageExists"
@@ -185,6 +191,7 @@ module.exports = {
          */
 		// Creator information tags
 		creatorUserId: { type: Number, required: true },
+		creatorHidden: { type: Boolean, required: true },
 		creatorName: { type: String, required: true },
 		creatorEditCount: { type: Number, required: true },
 		creatorRegistrationUTC: {
diff --git a/modules/ext.pageTriage.util/models/ext.pageTriage.article.js b/modules/ext.pageTriage.util/models/ext.pageTriage.article.js
index 0c61a0f1..578cd4d7 100644
--- a/modules/ext.pageTriage.util/models/ext.pageTriage.article.js
+++ b/modules/ext.pageTriage.util/models/ext.pageTriage.article.js
@@ -106,7 +106,8 @@ const Article = Backbone.Model.extend( {
 					article.get( 'creator_user_page_exist' )
 				)
 			);
-			article.set( 'user_contribs_title', article.get( 'creator_contribution_page' ) );
+		} else if ( article.get( 'creator_hidden' ) ) {
+			article.set( 'author_byline_html', mw.msg( 'rev-deleted-user' ) );
 		}
 
 		// Are there any PageTriage messages on the talk page?
diff --git a/modules/ext.pageTriage.views.list/ext.pageTriage.listItem.underscore b/modules/ext.pageTriage.views.list/ext.pageTriage.listItem.underscore
index 34cd4944..c809f976 100644
--- a/modules/ext.pageTriage.views.list/ext.pageTriage.listItem.underscore
+++ b/modules/ext.pageTriage.views.list/ext.pageTriage.listItem.underscore
@@ -105,7 +105,13 @@
 				<div class="mwe-pt-info-row">
 					<div class="mwe-pt-author">
 					<% if ( typeof( user_name ) !== "undefined" ) { %>
-						<%= author_byline_html %>
+						<% if ( !creator_hidden ) { %>
+							<%= author_byline_html %>
+						<% } else { %>
+							<span class="history-deleted mw-history-suppressed mw-userlink">
+								<%= author_byline_html %>
+							</span>
+						<% } %>
 						<!-- user_id is undefined or '0' for IP users -->
 						<% if ( typeof ( user_id ) != 'undefined' && Number( user_id ) !== 0 ) { %>
 							&#xb7;
diff --git a/modules/ext.pageTriage.views.toolbar/ToolView.js b/modules/ext.pageTriage.views.toolbar/ToolView.js
index 74a96746..f2544fec 100644
--- a/modules/ext.pageTriage.views.toolbar/ToolView.js
+++ b/modules/ext.pageTriage.views.toolbar/ToolView.js
@@ -267,6 +267,7 @@ module.exports = Backbone.View.extend( {
 			pageid: mw.config.get( 'wgArticleId' ),
 			title: mw.config.get( 'wgPageName' ),
 			creator: this.model.get( 'user_name' ),
+			creatorHidden: this.model.get( 'creator_hidden' ),
 			reviewed: reviewed
 		}, data );
 	},
diff --git a/modules/ext.pageTriage.views.toolbar/articleInfo.css b/modules/ext.pageTriage.views.toolbar/articleInfo.css
index a87be9c1..3d66ae3a 100644
--- a/modules/ext.pageTriage.views.toolbar/articleInfo.css
+++ b/modules/ext.pageTriage.views.toolbar/articleInfo.css
@@ -68,12 +68,3 @@
 .mew-pt-info-stat {
 	margin-top: 10px;
 }
-
-.mwe-pt-history-suppressed {
-	text-decoration-line: line-through;
-	text-decoration-style: double;
-}
-
-.mwe-pt-history-deleted {
-	text-decoration-line: line-through;
-}
diff --git a/modules/ext.pageTriage.views.toolbar/articleInfo.js b/modules/ext.pageTriage.views.toolbar/articleInfo.js
index 9b9ef2af..28eef292 100644
--- a/modules/ext.pageTriage.views.toolbar/articleInfo.js
+++ b/modules/ext.pageTriage.views.toolbar/articleInfo.js
@@ -109,6 +109,8 @@ module.exports = ToolView.extend( {
 			url.toString()
 		);
 
+		const offset = parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] );
+
 		// creator information
 		if ( this.model.get( 'user_name' ) ) {
 			// show new editor message only if the user is not anonymous and not autoconfirmed
@@ -119,7 +121,6 @@ module.exports = ToolView.extend( {
 				bylineMessage = 'pagetriage-articleinfo-byline';
 			}
 
-			const offset = parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] );
 			// put it all together in the byline
 			// The following messages are used here:
 			// * pagetriage-articleinfo-byline-new-editor
@@ -150,6 +151,13 @@ module.exports = ToolView.extend( {
 				)
 			).parse();
 			this.model.set( 'articleByline_html', articleByline );
+		} else if ( this.model.get( 'creator_hidden' ) ) {
+			this.model.set( 'articleByline_html', mw.msg( 'pagetriage-articleinfo-byline-hidden-username', moment.utc(
+				this.model.get( 'creation_date_utc' ),
+				'YYYYMMDDHHmmss'
+			).utcOffset( offset ).format(
+				mw.msg( 'pagetriage-info-timestamp-date-format' )
+			), mw.msg( 'rev-deleted-user' ) ) );
 		}
 
 		const stats = [
diff --git a/modules/ext.pageTriage.views.toolbar/articleInfoHistory.underscore b/modules/ext.pageTriage.views.toolbar/articleInfoHistory.underscore
index de6d9919..8ca5a877 100644
--- a/modules/ext.pageTriage.views.toolbar/articleInfoHistory.underscore
+++ b/modules/ext.pageTriage.views.toolbar/articleInfoHistory.underscore
@@ -4,20 +4,22 @@
 <% } %>
 </span>
 <li class="mwe-pt-info-history-entry">
+	<!-- TODO(sohom): This section seems to not be in line with what is shown in MediaWiki history,
+	we should clean this up and have it reflect the styles used in the MediaWiki history page -->
 	<% if ( (typeof suppressed) !== 'undefined' ) { %>
-		<span class="mwe-pt-history-suppressed"><%- timestamp_time %></span> &#xb7;
-		<span class="mwe-pt-history-suppressed"><%- mw.msg( 'rev-deleted-user' ) %></span>
-		&#xb7; <span class="mwe-pt-history-suppressed"><%- mw.msg( 'rev-deleted-comment' ) %></span>
+		<span class="history-deleted mw-history-suppressed"><%- timestamp_time %></span> &#xb7;
+		<span class="history-deleted mw-history-suppressed mw-userlink"><%- mw.msg( 'rev-deleted-user' ) %></span>
+		&#xb7; <span class="history-deleted mw-history-suppressed"><%- mw.msg( 'rev-deleted-comment' ) %></span>
 	<% } else { %>
 		<% if ( (typeof userhidden) !== 'undefined' ) { %>
-			<span class="mwe-pt-history-deleted"><%- timestamp_time %></span> &#xb7;
-			<span class="mwe-pt-history-deleted"><%- mw.msg( 'rev-deleted-user' ) %></span>
+			<span class="history-deleted"><%- timestamp_time %></span> &#xb7;
+			<span class="history-deleted mw-userlink"><%- mw.msg( 'rev-deleted-user' ) %></span>
 		<% } else { %>
 			<a href="<%- revision_url %>"><%- timestamp_time %></a> &#xb7;
 			<a href="<%- user_title_url %>"><%- user %></a>
 		<% } %>
 		<% if ( (typeof commenthidden) !== 'undefined' ) { %>
-			&#xb7; <span class="mwe-pt-history-deleted"><%- mw.msg( 'rev-deleted-comment' ) %></span>
+			&#xb7; <span class="history-deleted"><%- mw.msg( 'rev-deleted-comment' ) %></span>
 		<% } else if ( parsedcomment ) { %>
 			&#xb7; <%= parsedcomment %>
 		<% } %>
diff --git a/modules/ext.pageTriage.views.toolbar/mark.js b/modules/ext.pageTriage.views.toolbar/mark.js
index 4d347924..ea221f07 100644
--- a/modules/ext.pageTriage.views.toolbar/mark.js
+++ b/modules/ext.pageTriage.views.toolbar/mark.js
@@ -221,6 +221,7 @@ module.exports = ToolView.extend( {
 			status = this.model.get( 'patrol_status' ) === '0' ? 'reviewed' : 'unreviewed',
 			hasPreviousReviewer = this.model.get( 'ptrp_last_reviewed_by' ) > 0,
 			articleCreator = this.model.get( 'user_name' ),
+			articleCreatorHidden = this.model.get( 'creator_hidden' ),
 			previousReviewer = hasPreviousReviewer ? this.model.get( 'reviewer' ) : '';
 		let noteTarget = articleCreator,
 			notePlaceholder = 'pagetriage-message-for-creator-default-note',
@@ -239,7 +240,7 @@ module.exports = ToolView.extend( {
 			notePlaceholder = 'pagetriage-message-for-creator-default-note';
 		}
 
-		if ( mw.config.get( 'wgUserName' ) === articleCreator ) {
+		if ( mw.config.get( 'wgUserName' ) === articleCreator || articleCreatorHidden ) {
 			numRecipients--;
 			noteTarget = previousReviewer;
 			noteRecipientRole = 'reviewer';
diff --git a/modules/ext.pageTriage.views.toolbar/tags.js b/modules/ext.pageTriage.views.toolbar/tags.js
index c170cdce..57359040 100644
--- a/modules/ext.pageTriage.views.toolbar/tags.js
+++ b/modules/ext.pageTriage.views.toolbar/tags.js
@@ -321,7 +321,7 @@ module.exports = ToolView.extend( {
 		if ( this.selectedTagCount > 0 ) {
 			$( '#mwe-pt-tag-submit-button' ).button( 'enable' );
 			$( '#mwe-pt-checkbox-mark-reviewed-wrapper' ).show();
-			if ( mw.config.get( 'wgUserName' ) !== this.model.get( 'user_name' ) ) {
+			if ( mw.config.get( 'wgUserName' ) !== this.model.get( 'user_name' ) && !this.model.get( 'creator_hidden' ) ) {
 				$( '#mwe-pt-tag-note' ).show();
 			}
 		} else {
diff --git a/modules/ext.pageTriage.views.toolbar/wikiLove.js b/modules/ext.pageTriage.views.toolbar/wikiLove.js
index f493b069..22fe7d0a 100644
--- a/modules/ext.pageTriage.views.toolbar/wikiLove.js
+++ b/modules/ext.pageTriage.views.toolbar/wikiLove.js
@@ -26,11 +26,14 @@ module.exports = ToolView.extend( {
 	render: function () {
 		// get the article's creator
 		const creator = this.model.get( 'user_name' );
+		const creatorHidden = this.model.get( 'creator_hidden' );
 
 		// get the last 20 editors of the article
 		const contributorArray = [];
 		this.model.revisions.each( function ( revision ) {
-			contributorArray.push( revision.get( 'user' ) );
+			if ( typeof ( revision.get( 'userhidden' ) ) === 'undefined' ) {
+				contributorArray.push( revision.get( 'user' ) );
+			}
 		} );
 
 		// count how many times each editor edited the article
@@ -54,7 +57,7 @@ module.exports = ToolView.extend( {
 		// set the Learn More link URL
 		$( '#mwe-pt-wikilove .mwe-pt-flyout-help-link' ).attr( 'href', this.moduleConfig.helplink );
 
-		if ( mw.user.getName() !== creator ) {
+		if ( mw.user.getName() !== creator && !creatorHidden ) {
 			// add the creator info to the top of the list
 			$( '#mwe-pt-article-contributor-list' ).append(
 				'<input type="checkbox" class="mwe-pt-recipient-checkbox" value="' + _.escape( creator ) + '"/>' +
-- 
2.39.2 (Apple Git-143)

