Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | 7x 12x 7x 7x 26x 22x 7x 7x 7x 29x 7x 50x 26x 10x 12x 12x 1x 1x 12x 3x 3x 3x 3x 12x 12x 6x 6x 7x 7x 7x 7x 7x 6x 6x 6x 6x 6x 6x 6x 6x 1x 1x 1x 7x 5x 7x 1x 5x 5x 5x 5x 5x 5x 5x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 6x 6x 30x 6x 6x 20x 17x 6x 1x 14x 5x 3x 3x 3x 3x 2x 14x | import apiFetch from '@wordpress/api-fetch';
import { dispatch } from '@wordpress/data';
import { createElement, useSyncExternalStore } from '@wordpress/element';
import { __, sprintf, type TransformedText } from '@wordpress/i18n';
import { Icon, check } from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { recordEvent } from '../../data/analytics';
import { getThreatLabel } from './labels';
import { SCAN_PATH, createStore, getScan, mergeScan, setScan } from './store';
import type { ScanState, ScanThreat } from './types';
type FixStatus = { status: string; error?: string | null; scan?: ScanState };
/** The action running on a threat, if any. */
export type ThreatActionState = { busy?: 'fixing' | 'ignoring' | 'unignoring' | 'deleting' };
const threatPath = ( id: string | number, action: string ) =>
`${ SCAN_PATH }/threats/${ id }/${ action }`;
// Like the Protect plugin: check a running fix every few seconds, for about five minutes.
const FIX_POLL_INTERVAL = 3000;
const MAX_FIX_POLLS = 100;
// One id per threat, so its snackbar replaces its last ("Ignoring…" with "ignored") but not another threat's.
const noticeId = ( threat: ScanThreat ) => `jetpack-protect-threat-action-${ threat.id }`;
const describe = ( threat: ScanThreat ) => getThreatLabel( threat ).subject || threat.title || '';
// The store types `icon` as a string, but the snackbar renders any node; currentColor keeps it white.
const SUCCESS_ICON = createElement( Icon, {
icon: check,
fill: 'currentColor',
} ) as unknown as string;
const EMPTY: ThreatActionState = {};
const actionStore = createStore< Record< string, ThreatActionState > >( () => ( {} ) );
// Every action checks this first, so a second click can't send a duplicate request.
const isBusy = ( id: string | number ) => !! actionStore.get()[ String( id ) ]?.busy;
const setBusy = ( id: string | number, busy?: ThreatActionState[ 'busy' ] ) =>
actionStore.set( states => ( { ...states, [ String( id ) ]: busy ? { busy } : EMPTY } ) );
type NoticeStatus = 'success' | 'info' | 'error';
/** Opens a threat in the inspector. */
type OpenThreat = ( threat: ScanThreat ) => void;
type NoticeAction = { label: string; onClick: () => void };
/** A translated message with `%s` for the threat's name. */
type Template = TransformedText< `${ string }%s${ string }` >;
/**
* Show a threat's snackbar in the page's notices area, optionally with one action.
*
* @param threat - The threat the message is about.
* @param content - The finished message.
* @param status - The notice's kind.
* @param action - The snackbar's button, such as Undo.
*/
function show( threat: ScanThreat, content: string, status: NoticeStatus, action?: NoticeAction ) {
dispatch( noticesStore ).createNotice( status, content, {
type: 'snackbar',
id: noticeId( threat ),
// Progress and errors stay until dismissed, so they can't vanish before the action ends.
explicitDismiss: status !== 'success',
icon: status === 'success' ? SUCCESS_ICON : null,
actions: action ? [ action ] : [],
} );
}
/**
* Show a threat's snackbar from a message that names it.
*
* @param threat - The threat.
* @param template - The message, with `%s` for the threat's name.
* @param status - The notice's kind.
* @param action - The snackbar's button, such as Undo.
*/
function notify(
threat: ScanThreat,
template: Template,
status: NoticeStatus = 'success',
action?: NoticeAction
) {
show( threat, sprintf( template, describe( threat ) ), status, action );
}
/**
* Say an action has started, with a button that opens the threat in the inspector.
*
* @param threat - The threat.
* @param template - The message, with `%s` for the threat's name.
* @param open - Opens the threat in the inspector.
*/
function notifyStarted( threat: ScanThreat, template: Template, open?: OpenThreat ) {
const content = sprintf( template, describe( threat ) );
const view: NoticeAction | undefined = open && {
label: __( 'View', 'jetpack-protect-pkg' ),
// A snackbar removes itself when its action is clicked, so show it again.
onClick: () => {
open( threat );
show( threat, content, 'info', view );
},
};
show( threat, content, 'info', view );
}
/**
* End a failed action and say what went wrong.
*
* @param threat - The threat.
* @param e - The error, whose message follows the threat's name when it has one.
* @param template - What to say otherwise, with `%s` for the threat's name.
*/
function fail( threat: ScanThreat, e: unknown, template: Template ) {
setBusy( threat.id );
const reason = ( e as { message?: string } )?.message;
Iif ( reason ) {
const content = sprintf(
/* translators: 1: a threat, such as "Contact Form 7 (5.3.1)" or "index.php". 2: why the action failed. */
__( 'Threat in %1$s: %2$s', 'jetpack-protect-pkg' ),
describe( threat ),
reason
);
show( threat, content, 'error' );
} else {
notify( threat, template, 'error' );
}
}
const otherThan = ( threat: ScanThreat ) => ( item: ScanThreat ) =>
String( item.id ) !== String( threat.id );
/**
* Fetch the threats the site ignored, for the Ignored view.
*
* @return Resolves once the list is in the store; failures leave it unloaded.
*/
export function loadIgnored(): Promise< void > {
return apiFetch< ScanThreat[] >( { path: `${ SCAN_PATH }/ignored` } )
.then( ignored => mergeScan( { ignored } ) )
.catch( () => {} );
}
/**
* Ignore or unignore a threat, moving it between the active and ignored lists, with Undo.
*
* @param threat - The threat.
* @param ignore - True to ignore, false to unignore.
* @param open - Opens the threat in the inspector, from the notice while it runs.
* @return Resolves once done, or once the error is shown.
*/
function setIgnored( threat: ScanThreat, ignore: boolean, open?: OpenThreat ): Promise< void > {
Iif ( isBusy( threat.id ) ) {
return Promise.resolve();
}
setBusy( threat.id, ignore ? 'ignoring' : 'unignoring' );
// The Scan page's names, so its dashboards count these too.
recordEvent( ignore ? 'jetpack_scan_ignore_threat_click' : 'jetpack_scan_unignore_threat_click' );
notifyStarted(
threat,
ignore
? /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'Ignoring the threat in %s…', 'jetpack-protect-pkg' )
: /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'Unignoring the threat in %s…', 'jetpack-protect-pkg' ),
open
);
return apiFetch( {
path: threatPath( threat.id, ignore ? 'ignore' : 'unignore' ),
method: 'POST',
} )
.then( () => {
const moved = { ...threat, status: ignore ? ( 'ignored' as const ) : ( 'current' as const ) };
setScan( current => {
const threats = ( current.threats ?? [] ).filter( otherThan( threat ) );
const ignored = ( current.ignored ?? [] ).filter( otherThan( threat ) );
return ignore
? { ...current, threats, ignored: [ moved, ...ignored ] }
: { ...current, threats: [ moved, ...threats ], ignored };
} );
setBusy( threat.id );
recordEvent(
ignore ? 'jetpack_scan_ignore_threat_success' : 'jetpack_scan_unignore_threat_success'
);
notify(
threat,
ignore
? /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'Ignored the threat in %s.', 'jetpack-protect-pkg' )
: /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'Unignored the threat in %s.', 'jetpack-protect-pkg' ),
'success',
{
label: __( 'Undo', 'jetpack-protect-pkg' ),
onClick: () => setIgnored( threat, ! ignore, open ),
}
);
} )
.catch( e => {
recordEvent(
ignore ? 'jetpack_scan_ignore_threat_failed' : 'jetpack_scan_unignore_threat_failed'
);
fail(
threat,
e,
ignore
? /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'The threat in %s couldn’t be ignored.', 'jetpack-protect-pkg' )
: /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'The threat in %s couldn’t be unignored.', 'jetpack-protect-pkg' )
);
} );
}
export const ignoreThreat = ( threat: ScanThreat, open?: OpenThreat ) =>
setIgnored( threat, true, open );
export const unignoreThreat = ( threat: ScanThreat, open?: OpenThreat ) =>
setIgnored( threat, false, open );
/**
* Ask Scan to fix a threat, then follow the fix until it finishes.
*
* @param threat - The threat.
* @param open - Opens the threat in the inspector, from the notice while it runs.
* @return Resolves once the fix has finished or failed.
*/
export async function fixThreat( threat: ScanThreat, open?: OpenThreat ): Promise< void > {
const { id } = threat;
Iif ( isBusy( id ) ) {
return;
}
setBusy( id, 'fixing' );
recordEvent( 'jetpack_scan_fix_threat_click' );
/* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
notifyStarted( threat, __( 'Fixing the threat in %s…', 'jetpack-protect-pkg' ), open );
try {
let result = await apiFetch< FixStatus >( { path: threatPath( id, 'fix' ), method: 'POST' } );
for ( let polls = 0; result.status !== 'fixed' && result.status !== 'not_fixed'; polls++ ) {
if ( polls >= MAX_FIX_POLLS ) {
recordEvent( 'jetpack_scan_fix_threat_failed' );
return fail(
threat,
null,
/* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__(
'The fix for the threat in %s is taking longer than expected. Check back in a few minutes.',
'jetpack-protect-pkg'
)
);
}
await new Promise( resolve => setTimeout( resolve, FIX_POLL_INTERVAL ) );
result = await apiFetch< FixStatus >( { path: threatPath( id, 'fix' ) } );
}
mergeScan( result.scan );
if ( result.status === 'fixed' ) {
setBusy( id );
recordEvent( 'jetpack_scan_fix_threat_success' );
/* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
return notify( threat, __( 'Fixed the threat in %s.', 'jetpack-protect-pkg' ) );
}
recordEvent( 'jetpack_scan_fix_threat_failed' );
fail(
threat,
{ message: result.error },
/* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__(
'Jetpack couldn’t fix the threat in %s. Contact Jetpack support for help.',
'jetpack-protect-pkg'
)
);
} catch ( e ) {
recordEvent( 'jetpack_scan_fix_threat_failed' );
fail(
threat,
e,
/* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */
__( 'The fix for the threat in %s couldn’t be started.', 'jetpack-protect-pkg' )
);
}
}
/**
* What is happening to a threat, re-rendering when it changes.
*
* @param id - The threat id.
* @return The threat's action state.
*/
export function useThreatAction( id: string | number ): ThreatActionState {
return useSyncExternalStore(
actionStore.subscribe,
() => actionStore.get()[ String( id ) ] ?? EMPTY
);
}
/**
* Delete the plugin or theme a threat is in, and drop every threat reported in it.
*
* @param threat - A threat in an inactive plugin or an unused theme.
* @return Resolves to why the delete failed, or null once it's done.
*/
export function deleteSoftware( threat: ScanThreat ): Promise< string | null > {
const { type, slug, name } = threat.extension ?? {};
const isElsewhere = ( item: ScanThreat ) =>
item.extension?.type !== type || item.extension?.slug !== slug;
const scan = getScan();
const ids = [ threat, ...( scan?.threats ?? [] ), ...( scan?.ignored ?? [] ) ]
.filter( item => ! isElsewhere( item ) )
.map( item => item.id );
// A fix or ignore that ends after the delete would put the deleted threats back.
if ( ids.some( isBusy ) ) {
return Promise.resolve(
sprintf(
/* translators: %s is a plugin or theme, such as "Contact Form 7". */
__(
'%s can’t be deleted while one of its threats is being fixed or ignored. Try again once that finishes.',
'jetpack-protect-pkg'
),
name || slug
)
);
}
ids.forEach( id => setBusy( id, 'deleting' ) );
return apiFetch( {
path: `${ SCAN_PATH }/software/delete`,
method: 'POST',
data: { type, slug },
} )
.then( () => {
setScan( current => ( {
...current,
threats: ( current.threats ?? [] ).filter( isElsewhere ),
ignored: current.ignored?.filter( isElsewhere ),
} ) );
const content = sprintf(
/* translators: %s is a plugin or theme, such as "Contact Form 7". */
__( 'Deleted %s.', 'jetpack-protect-pkg' ),
name || slug
);
show( threat, content, 'success' );
return null;
} )
.catch(
( e: { message?: string } ) =>
e?.message ||
sprintf(
/* translators: %s is a plugin or theme, such as "Contact Form 7". */
__( '%s couldn’t be deleted.', 'jetpack-protect-pkg' ),
name || slug
)
)
.finally( () => ids.forEach( id => setBusy( id ) ) );
}
|