Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/admin/build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element'), 'version' => '5580cf5e497048ad6da1');
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element'), 'version' => 'b2996cbb70403a634fea');
2 changes: 1 addition & 1 deletion src/admin/build/index.js

Large diffs are not rendered by default.

21 changes: 14 additions & 7 deletions src/admin/inc/class-ss-admin-rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,12 @@ function () use ( $job ) {

$sites = apply_filters( 'ss_rest_multisite_get_sites', $sites );

return wp_send_json_success( $sites );
return rest_ensure_response(
array(
'success' => true,
'data' => $sites,
)
);
}

/** Multisite: trigger cron on a specific site */
Expand Down Expand Up @@ -1568,11 +1573,13 @@ public function get_activity_log( $request ) {
$activity_log = Plugin::instance()->get_activity_log( $blog_id );
$running = Plugin::instance()->get_archive_creation_job()->is_running();

return json_encode( [
'status' => 200,
'data' => $activity_log,
'running' => $running,
] );
return rest_ensure_response(
array(
'status' => 200,
'data' => $activity_log,
'running' => $running,
)
);
} );
}

Expand All @@ -1587,7 +1594,7 @@ public function get_export_log( $request ) {
return $this->run_in_blog_context( $blog_id, function () use ( $per_page, $page, $blog_id, $search ) {
$export_log = Plugin::instance()->get_export_log( $per_page, $page, $blog_id, $search );

return json_encode( [ 'status' => 200, 'data' => $export_log ] );
return rest_ensure_response( array( 'status' => 200, 'data' => $export_log ) );
} );
}

Expand Down
6 changes: 2 additions & 4 deletions src/admin/src/settings/components/ActivityLog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Terminal, { ColorMode, TerminalOutput } from 'react-terminal-ui';
import apiFetch from '@wordpress/api-fetch';
import useInterval from '../../hooks/useInterval';
import {
parseActivityLogMessage,
parseActivityLogEntry,
parseLogResponse,
toInertLogText,
} from '../utils/log';
Expand Down Expand Up @@ -110,9 +110,7 @@ function ActivityLog() {
const safeEntry =
entry && typeof entry === 'object' ? entry : {};
const date = toInertLogText( safeEntry.datetime );
const content = parseActivityLogMessage(
safeEntry.message
);
const content = parseActivityLogEntry( safeEntry );
const error =
message.includes( 'pause' ) ||
message.includes( 'cancel' );
Expand Down
28 changes: 28 additions & 0 deletions src/admin/src/settings/components/LogComponents.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,34 @@ describe( 'ActivityLog', () => {
expect( container.querySelector( '[onclick]' ) ).toBeNull();
} );

it( 'renders a structured destination link without server HTML', async () => {
apiFetch.mockResolvedValue(
{
status: 200,
data: {
destination_url: {
datetime: '2026-07-12 10:00:00',
message: 'Destination URL:',
link: {
url: 'https://static.example.com/',
label: 'https://static.example.com/',
},
},
},
}
);

renderWithContext( <ActivityLog />, activityContext );
const link = await screen.findByRole( 'link', {
name: 'https://static.example.com/',
} );

expect( link.getAttribute( 'href' ) ).toBe(
'https://static.example.com/'
);
expect( screen.getByText( /Destination URL:/ ) ).not.toBeNull();
} );

it( 'keeps an unsafe completion anchor inert', async () => {
apiFetch.mockResolvedValue(
JSON.stringify( {
Expand Down
32 changes: 32 additions & 0 deletions src/admin/src/settings/utils/log.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ export function parseActivityLogMessage( value ) {
};
}

/**
* Convert a complete activity-log entry to safe text and optional link data.
*
* New entries carry structured link data so their rendering does not depend
* on parsing HTML. The message parser remains as a fallback for entries saved
* by older plugin versions and third-party deployment methods.
*
* @param {*} entry Activity-log entry.
* @return {{before: string, after: string, link: {href: string, label: string}|null}} Parsed entry.
*/
export function parseActivityLogEntry( entry ) {
const safeEntry = entry && typeof entry === 'object' ? entry : {};
const content = parseActivityLogMessage( safeEntry.message );

if ( ! safeEntry.link || typeof safeEntry.link !== 'object' ) {
return content;
}

const safeUrl = getSafeLogUrl( safeEntry.link.url );
if ( ! safeUrl ) {
return content;
}

return {
...content,
link: {
href: safeUrl.href,
label: toInertLogText( safeEntry.link.label ) || safeUrl.label,
},
};
}

/**
* Parse the tightly allowlisted attributes accepted on a legacy log anchor.
*
Expand Down
38 changes: 38 additions & 0 deletions src/admin/src/settings/utils/log.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getSafeLogUrl,
parseActivityLogEntry,
parseActivityLogMessage,
parseLogResponse,
toInertLogText,
Expand Down Expand Up @@ -84,6 +85,43 @@ describe( 'log utilities', () => {
} );
} );

describe( 'parseActivityLogEntry', () => {
it( 'prefers validated structured link data', () => {
expect(
parseActivityLogEntry( {
message: 'ZIP archive created:',
link: {
url: '/wp-content/uploads/simply-static/archive.zip',
label: 'Click here to download',
},
} )
).toEqual( {
before: 'ZIP archive created:',
after: '',
link: {
href: '/wp-content/uploads/simply-static/archive.zip',
label: 'Click here to download',
},
} );
} );

it( 'falls back to a legacy link when structured link data is unsafe', () => {
expect(
parseActivityLogEntry( {
message:
'Destination URL: <a href="https://static.example.com/">Static site</a>',
link: {
url: 'javascript:alert(1)',
label: '<img src=x onerror=alert(1)>',
},
} ).link
).toEqual( {
href: 'https://static.example.com/',
label: 'Static site',
} );
} );
} );

describe( 'getSafeLogUrl', () => {
it.each( [
'javascript:alert(1)',
Expand Down
16 changes: 16 additions & 0 deletions src/class-ss-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,22 @@ public function get_activity_log( $blog_id = 0 ) {
}
$log[ $key ]['message'] = wp_kses_post( isset( $entry['message'] ) ? $entry['message'] : '' );
$log[ $key ]['datetime'] = sanitize_text_field( isset( $entry['datetime'] ) ? $entry['datetime'] : '' );

if ( isset( $entry['link'] ) && is_array( $entry['link'] ) ) {
$link_url = esc_url_raw( isset( $entry['link']['url'] ) ? $entry['link']['url'] : '' );
$link_label = sanitize_text_field( isset( $entry['link']['label'] ) ? $entry['link']['label'] : '' );

if ( $link_url && preg_match( '#^(?:https?://|/(?!/))#i', $link_url ) ) {
$log[ $key ]['link'] = array(
'url' => $link_url,
'label' => $link_label ?: $link_url,
);
} else {
unset( $log[ $key ]['link'] );
}
} elseif ( array_key_exists( 'link', $entry ) ) {
unset( $log[ $key ]['link'] );
}
}

do_action( 'ss_after_render_activity_log', $blog_id, $this->get_archive_creation_job() );
Expand Down
12 changes: 11 additions & 1 deletion src/class-ss-util.php
Original file line number Diff line number Diff line change
Expand Up @@ -2781,10 +2781,11 @@ public static function remove_leading_slash( $path ) {
* @param string $task_name Name of the task
* @param string $message Message to display about the status of the job
* @param boolean $unique If unique, the task_name/key will get a prefix if the same exists.
* @param array|null $link Optional link data with URL and label.
*
* @return array
*/
public static function add_archive_status_message( $messages, $task_name, $message, $unique = false ) {
public static function add_archive_status_message( $messages, $task_name, $message, $unique = false, $link = null ) {
if ( ! is_array( $messages ) ) {
$messages = array();
}
Expand All @@ -2802,6 +2803,15 @@ public static function add_archive_status_message( $messages, $task_name, $messa
$messages[ $task_name ]['message'] = $message;
}

if ( is_array( $link ) && ! empty( $link['url'] ) ) {
$messages[ $task_name ]['link'] = array(
'url' => $link['url'],
'label' => isset( $link['label'] ) ? $link['label'] : $link['url'],
);
} else {
unset( $messages[ $task_name ]['link'] );
}

return $messages;
}

Expand Down
8 changes: 5 additions & 3 deletions src/tasks/class-ss-create-zip-archive.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ public function perform() {
}

$message = __( 'ZIP archive created: ', 'simply-static' );
$link = array(
'url' => $download_url,
'label' => __( 'Click here to download', 'simply-static' ),
);
if ( $this->is_wp_cli_running() ) {
$message .= $download_url;
} else {
$message .= ' <a href="' . esc_url( $download_url ) . '">' . esc_html__( 'Click here to download', 'simply-static' ) . '</a>';
}

$this->save_status_message( $message );
$this->save_status_message( $message, null, $link );

return true;
}
Expand Down
10 changes: 6 additions & 4 deletions src/tasks/class-ss-task.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ public function __construct() {
* provided, the state_name will be used. Using the same key more than once
* will overwrite previous messages.
*
* @param string $message Message to display about the status of the job.
* @param string $key Unique key for the message.
* @param string $message Message to display about the status of the job.
* @param string $key Unique key for the message.
* @param array|null $link Optional link data with URL and label.
*
* @return void
*/
protected function save_status_message( $message, $key = null ) {
protected function save_status_message( $message, $key = null, $link = null ) {
$task_name = $key ?: static::$task_name;
$messages = $this->options->get( 'archive_status_messages' );
Util::debug_log( 'Status message: [' . $task_name . '] ' . $message );

$messages = Util::add_archive_status_message( $messages, $task_name, $message );
$messages = Util::add_archive_status_message( $messages, $task_name, $message, false, $link );

$this->options
->set( 'archive_status_messages', $messages )
Expand Down
10 changes: 8 additions & 2 deletions src/tasks/class-ss-transfer-files-locally-task.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,14 @@ public function perform() {

if ( $this->options->get( 'destination_url_type' ) == 'absolute' ) {
$destination_url = trailingslashit( $this->options->get_destination_url() );
$message = __( 'Destination URL:', 'simply-static' ) . ' <a href="' . esc_url( $destination_url ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $destination_url ) . '</a>';
$this->save_status_message( $message, 'destination_url' );
$this->save_status_message(
__( 'Destination URL:', 'simply-static' ),
'destination_url',
array(
'url' => $destination_url,
'label' => $destination_url,
)
);
}

// If this is a 404-only export, ensure the activity/export log reflects a single transferred file.
Expand Down
28 changes: 14 additions & 14 deletions tests/Unit/AdminControllerCoverageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -433,28 +433,28 @@ public function get_archive_creation_job() {
};
$this->pluginInstanceProperty()->setValue( null, $plugin );

$activity = json_decode(
(string) $this->rest->get_activity_log( new \WP_REST_Request( array( 'blog_id' => '-9' ) ) ),
true
$activity_response = $this->rest->get_activity_log(
new \WP_REST_Request( array( 'blog_id' => '-9' ) )
);
self::assertInstanceOf( \WP_REST_Response::class, $activity_response );
$activity = $activity_response->get_data();
self::assertSame( array( 9 ), $plugin->activity_blog_ids );
self::assertSame( 200, $activity['status'] ?? null );
self::assertTrue( $activity['running'] ?? false );
self::assertSame( 'Ready', $activity['data']['fetch']['message'] ?? null );

$export = json_decode(
(string) $this->rest->get_export_log(
new \WP_REST_Request(
array(
'blog_id' => '-3',
'per_page' => 9999,
'page' => 0,
'search' => "<b> needle </b>\nvalue",
)
$export_response = $this->rest->get_export_log(
new \WP_REST_Request(
array(
'blog_id' => '-3',
'per_page' => 9999,
'page' => 0,
'search' => "<b> needle </b>\nvalue",
)
),
true
)
);
self::assertInstanceOf( \WP_REST_Response::class, $export_response );
$export = $export_response->get_data();
self::assertSame( array( 200, 1, 3, 'needle value' ), $plugin->export_calls[0] );
self::assertSame( 200, $export['status'] ?? null );
self::assertSame( array(), $export['data']['static_pages'] ?? null );
Expand Down
24 changes: 24 additions & 0 deletions tests/Unit/PluginTaskLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,22 @@ public function test_activity_log_drops_malformed_entries_and_sanitizes_user_vis
'datetime' => "2026-07-12\n12:00:00",
),
'partial' => array(),
'linked' => array(
'message' => 'Destination URL:',
'datetime' => '2026-07-12 12:00:00',
'link' => array(
'url' => 'https://static.example.com/',
'label' => "Static\nsite",
),
),
'unsafe_link' => array(
'message' => 'Unsafe',
'datetime' => '2026-07-12 12:00:00',
'link' => array(
'url' => 'javascript:alert(1)',
'label' => 'Unsafe link',
),
),
)
);

Expand All @@ -202,6 +218,14 @@ public function test_activity_log_drops_malformed_entries_and_sanitizes_user_vis
self::assertSame( '<strong>Saved</strong>', $log['good']['message'] );
self::assertSame( '2026-07-12 12:00:00', $log['good']['datetime'] );
self::assertSame( array( 'message' => '', 'datetime' => '' ), $log['partial'] );
self::assertSame(
array(
'url' => 'https://static.example.com/',
'label' => 'Static site',
),
$log['linked']['link']
);
self::assertArrayNotHasKey( 'link', $log['unsafe_link'] );
self::assertContains( 'ss_before_render_activity_log', WpEnv::$action_log );
self::assertContains( 'ss_after_render_activity_log', WpEnv::$action_log );
}
Expand Down
Loading
Loading