diff --git a/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/jobswp-contact-form.php b/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/jobswp-contact-form.php
index ef363d7502..59472205f2 100644
--- a/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/jobswp-contact-form.php
+++ b/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/jobswp-contact-form.php
@@ -36,6 +36,13 @@ class Jobs_Dot_WP_Contact_Form {
*/
const RATE_LIMIT_WINDOW = 600;
+ /**
+ * Query var carrying the ID of the job being reported.
+ *
+ * @var string
+ */
+ const REPORT_QUERY_VAR = 'report_job';
+
/**
* Initializes plugin.
*/
@@ -45,6 +52,56 @@ public static function init() {
add_action( 'jobswp_contact_form_fields', [ Jobs_Dot_WP_Captcha::class, 'output_recaptcha_field' ] );
}
+ /**
+ * Gets the URL of the feedback form prefilled to report a job.
+ *
+ * @param int $job_id Job post ID.
+ * @return string Feedback page URL.
+ */
+ public static function get_report_url( $job_id ) {
+ return add_query_arg( self::REPORT_QUERY_VAR, absint( $job_id ), home_url( '/feedback/' ) );
+ }
+
+ /**
+ * Gets the prefilled subject for reporting a job.
+ *
+ * Built server-side from the job ID so links cannot prefill arbitrary text.
+ *
+ * @param int $job_id Job post ID.
+ * @return string Subject, or an empty string if the job is not a published job.
+ */
+ public static function get_report_subject( $job_id ) {
+ $job = get_post( $job_id );
+
+ if ( ! $job || 'job' !== $job->post_type || 'publish' !== $job->post_status ) {
+ return '';
+ }
+
+ // Raw title (not get_the_title()) to avoid texturized entities. Stored titles are kses-encoded (&), so decode to plain text.
+ $title = sanitize_text_field( wp_specialchars_decode( $job->post_title, ENT_QUOTES ) );
+
+ // validate() limits the subject to 200 bytes, so truncate by bytes without splitting a multibyte character.
+ if ( strlen( $title ) > 150 ) {
+ if ( function_exists( 'mb_strcut' ) ) {
+ $title = mb_strcut( $title, 0, 150, 'UTF-8' );
+ } else {
+ // Core does not polyfill mb_strcut(); drop any partial multibyte character left at the end.
+ $title = substr( $title, 0, 150 );
+ while ( '' !== $title && 1 !== preg_match( '//u', $title ) ) {
+ $title = substr( $title, 0, -1 );
+ }
+ }
+ $title .= '…';
+ }
+
+ return sprintf(
+ /* translators: 1: Job title, 2: Job ID */
+ __( 'Reporting - %1$s (%2$d)', 'jobswp' ),
+ $title,
+ $job->ID
+ );
+ }
+
/**
* Processes POST submissions before output is sent.
*/
@@ -334,6 +391,13 @@ public static function shortcode() {
$subject = isset( $fields['subject'] ) ? $fields['subject'] : '';
$message = isset( $fields['message'] ) ? $fields['message'] : '';
+ // Flash values (a failed submission) take priority over the report prefill.
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only prefill of a form field.
+ if ( '' === $subject && isset( $_GET[ self::REPORT_QUERY_VAR ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only prefill of a form field.
+ $subject = self::get_report_subject( absint( wp_unslash( $_GET[ self::REPORT_QUERY_VAR ] ) ) );
+ }
+
ob_start();
if ( $error ) {
diff --git a/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/tests/Report_Job_Test.php b/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/tests/Report_Job_Test.php
new file mode 100644
index 0000000000..8c223e5de1
--- /dev/null
+++ b/jobs.wordpress.net/public_html/wp-content/plugins/jobswp/tests/Report_Job_Test.php
@@ -0,0 +1,221 @@
+
+ */
+ private array $saved_globals = array();
+
+ /**
+ * Saves the globals the shortcode test replaces.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->saved_globals = array(
+ 'post' => $GLOBALS['post'] ?? null,
+ 'wp_query' => $GLOBALS['wp_query'] ?? null,
+ );
+ }
+
+ /**
+ * Removes created posts and restores request globals.
+ *
+ * @return void
+ */
+ protected function tearDown(): void {
+ unset( $_GET[ Jobs_Dot_WP_Contact_Form::REPORT_QUERY_VAR ] );
+
+ foreach ( $this->post_ids as $post_id ) {
+ wp_delete_post( $post_id, true );
+ }
+ $this->post_ids = array();
+
+ $GLOBALS['post'] = $this->saved_globals['post'];
+ $GLOBALS['wp_query'] = $this->saved_globals['wp_query'];
+
+ parent::tearDown();
+ }
+
+ /**
+ * Creates a post and tracks it for cleanup.
+ *
+ * @param array $args Post arguments overriding the defaults.
+ * @return int Post ID.
+ */
+ private function create_post( array $args = array() ): int {
+ $post_id = wp_insert_post(
+ array_merge(
+ array(
+ 'post_type' => 'job',
+ 'post_status' => 'publish',
+ 'post_title' => 'Senior WordPress Developer',
+ 'post_content' => 'Description.',
+ ),
+ $args
+ ),
+ true
+ );
+ $this->assertIsInt( $post_id, 'The post could not be created.' );
+ $this->post_ids[] = $post_id;
+
+ return $post_id;
+ }
+
+ /**
+ * A published job yields the expected subject.
+ *
+ * @return void
+ */
+ public function test_published_job_subject(): void {
+ $id = $this->create_post();
+
+ $this->assertSame(
+ sprintf( 'Reporting - Senior WordPress Developer (%d)', $id ),
+ Jobs_Dot_WP_Contact_Form::get_report_subject( $id )
+ );
+ }
+
+ /**
+ * A draft job yields no subject.
+ *
+ * @return void
+ */
+ public function test_draft_job_has_no_subject(): void {
+ $id = $this->create_post( array( 'post_status' => 'draft' ) );
+
+ $this->assertSame( '', Jobs_Dot_WP_Contact_Form::get_report_subject( $id ) );
+ }
+
+ /**
+ * A non-job post yields no subject.
+ *
+ * @return void
+ */
+ public function test_non_job_has_no_subject(): void {
+ $id = $this->create_post( array( 'post_type' => 'post' ) );
+
+ $this->assertSame( '', Jobs_Dot_WP_Contact_Form::get_report_subject( $id ) );
+ }
+
+ /**
+ * A nonexistent ID yields no subject.
+ *
+ * @return void
+ */
+ public function test_nonexistent_job_has_no_subject(): void {
+ $this->assertSame( '', Jobs_Dot_WP_Contact_Form::get_report_subject( 999999 ) );
+ }
+
+ /**
+ * A very long title is truncated so the subject fits the form's 200 character limit.
+ *
+ * @return void
+ */
+ public function test_long_title_fits_subject_limit(): void {
+ $id = $this->create_post( array( 'post_title' => str_repeat( 'A', 300 ) ) );
+ $subject = Jobs_Dot_WP_Contact_Form::get_report_subject( $id );
+
+ $this->assertNotSame( '', $subject );
+ $this->assertLessThanOrEqual( 200, mb_strlen( $subject ) );
+ // The form validates with strlen() (bytes), so the limit must hold there too.
+ $this->assertLessThanOrEqual( 200, strlen( $subject ) );
+ }
+
+ /**
+ * A long multibyte title still fits the byte-based limit, without broken characters.
+ *
+ * @return void
+ */
+ public function test_long_multibyte_title_fits_subject_limit(): void {
+ $id = $this->create_post( array( 'post_title' => str_repeat( 'ワードプレス', 50 ) ) );
+ $subject = Jobs_Dot_WP_Contact_Form::get_report_subject( $id );
+
+ $this->assertStringStartsWith( 'Reporting - ワ', $subject );
+ $this->assertLessThanOrEqual( 200, strlen( $subject ) );
+ $this->assertTrue( mb_check_encoding( $subject, 'UTF-8' ) );
+ }
+
+ /**
+ * Apostrophes and ampersands stay plain text rather than becoming HTML entities.
+ *
+ * @return void
+ */
+ public function test_title_stays_plain_text(): void {
+ $id = $this->create_post( array( 'post_title' => "Dev's Role & Support" ) );
+ $subject = Jobs_Dot_WP_Contact_Form::get_report_subject( $id );
+
+ $this->assertSame( sprintf( "Reporting - Dev's Role & Support (%d)", $id ), $subject );
+ $this->assertStringNotContainsString( '', $subject );
+ $this->assertStringNotContainsString( '&', $subject );
+ }
+
+ /**
+ * The report URL points at the feedback page with the job ID.
+ *
+ * @return void
+ */
+ public function test_report_url(): void {
+ $url = Jobs_Dot_WP_Contact_Form::get_report_url( 8 );
+
+ $this->assertStringContainsString( '/feedback/', $url );
+ $this->assertStringContainsString( 'report_job=8', $url );
+ }
+
+ /**
+ * The contact form prefills its subject field from the report query var.
+ *
+ * @return void
+ */
+ public function test_shortcode_prefills_subject(): void {
+ $job_id = $this->create_post( array( 'post_title' => "Dev's Role & Support" ) );
+ $page_id = $this->create_post(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Feedback',
+ 'post_content' => '[jobswp-contact-form]',
+ )
+ );
+
+ $query = new WP_Query( array( 'page_id' => $page_id ) );
+ $query->get_posts();
+ $GLOBALS['wp_query'] = $query;
+ $GLOBALS['post'] = get_post( $page_id );
+
+ $_GET[ Jobs_Dot_WP_Contact_Form::REPORT_QUERY_VAR ] = (string) $job_id;
+
+ $html = Jobs_Dot_WP_Contact_Form::shortcode();
+
+ $expected = sprintf( "Reporting - Dev's Role & Support (%d)", $job_id );
+ $this->assertStringContainsString( 'value="' . esc_attr( $expected ) . '"', $html );
+
+ $_GET[ Jobs_Dot_WP_Contact_Form::REPORT_QUERY_VAR ] = '999999';
+ $this->assertStringNotContainsString( 'Reporting -', Jobs_Dot_WP_Contact_Form::shortcode() );
+ }
+}
diff --git a/jobs.wordpress.net/public_html/wp-content/themes/jobswp/content-single.php b/jobs.wordpress.net/public_html/wp-content/themes/jobswp/content-single.php
index 393ffb7950..45ab1fc5a6 100644
--- a/jobs.wordpress.net/public_html/wp-content/themes/jobswp/content-single.php
+++ b/jobs.wordpress.net/public_html/wp-content/themes/jobswp/content-single.php
@@ -83,11 +83,19 @@