<?php
namespace Imagely\NGG\Lightroom;
// phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound
use Imagely\NGG\DataMappers\Album as AlbumMapper;
use Imagely\NGG\DataMappers\Gallery as GalleryMapper;
use Imagely\NGG\DataMappers\Image as ImageMapper;
use Imagely\NGG\DataStorage\Manager as StorageManager;
use Imagely\NGG\Util\{Filesystem, MassAssignment, Security};
/**
* Controller for Lightroom integration.
*/
class Controller {
/** Throttle key for the anonymous execute endpoint's refusal log. */
const REFUSAL_LOG_THROTTLE = 'ngg_lr_refusal_logged';
/**
* NextGEN API instance.
*
* @var object|null
*/
protected $nextgen_api = null;
/**
* Whether NextGEN API is locked.
*
* @var bool
*/
protected $nextgen_api_locked = false;
/**
* Whether shutdown is registered.
*
* @var bool
*/
protected $shutdown_registered = false;
// Nonce verification not possible: the Lightroom client never requests or sends back a nonce. All actions are
// authenticated.
//
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:disable WordPress.Security.NonceVerification.Recommended
public static function run() {
define( 'DOING_AJAX', true );
ob_start();
$self = new Controller();
$action = $self->param( 'action' ) . '_action';
$response = [];
// The following could be dynamic but is written this way to prevent warnings that the methods aren't in use.
if ( 'enqueue_nextgen_api_task_list_action' === $action ) {
$response = $self->enqueue_nextgen_api_task_list_action();
} elseif ( 'execute_nextgen_api_task_list_action' === $action ) {
$response = $self->execute_nextgen_api_task_list_action();
} elseif ( 'get_nextgen_api_path_list_action' === $action ) {
$response = $self->get_nextgen_api_path_list_action();
} elseif ( 'get_nextgen_api_token_action' === $action ) {
$response = $self->get_nextgen_api_token_action();
}
// Flush the buffer.
$buffer_limit = 0;
$zlib = ini_get( 'zlib.output_compression' );
if ( ! is_numeric( $zlib ) && $zlib == 'On' ) {
$buffer_limit = 1;
} elseif ( is_numeric( $zlib ) && $zlib > 0 ) {
$buffer_limit = 1;
}
while ( ob_get_level() != $buffer_limit ) {
ob_end_clean();
}
wp_send_json( $response );
}
/**
* Gets a request parameter value.
*
* @param string $key
* @return mixed
*/
public function param( $key ) {
if ( isset( $_REQUEST[ $key ] ) ) {
return $this->recursive_stripslashes( sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) );
}
}
/**
* Gets a JSON parameter value, undoes WordPress magic-quoting, and decodes it.
*
* Unlike param(), this method calls wp_unslash() exactly once (to undo WordPress's
* wp_magic_quotes() which runs addslashes() on $_REQUEST at startup), but does NOT
* call recursive_stripslashes() a second time. That second call in param() corrupts
* JSON-encoded Windows paths: C:\\Users\\ becomes C:\Users\, leaving invalid JSON
* escape sequences (\U, \A, etc.) that cause json_decode() to return null.
*
* @param string $key
* @return mixed Decoded value, or null if the parameter is absent or not valid JSON.
*/
public function param_json( $key ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$raw = isset( $_REQUEST[ $key ] ) ? $_REQUEST[ $key ] : null;
if ( is_string( $raw ) ) {
// WordPress's wp_magic_quotes() applies addslashes() to $_REQUEST at startup,
// escaping all " to \" and \ to \\. We must call wp_unslash() once to undo
// that and restore valid JSON. We must NOT call recursive_stripslashes() a
// second time (as param() does), because that strips the path backslashes again
// (C:\\Users\\ -> C:\Users\), leaving invalid escape sequences (\U, \A, etc.)
// that cause json_decode() to return null on Windows paths.
return json_decode( wp_unslash( $raw ), true );
}
return $raw;
}
/**
* Recursively calls stripslashes() on strings, arrays, and objects
*
* Copied here from RoutingApp to maintain compatibility with Lightroom
*
* @TODO Move this to a better place or find a better solution
* @param string|array|\stdClass $value Value to be processed
* @return string|array|\stdClass Resulting value
*/
public function recursive_stripslashes( $value ) {
if ( is_string( $value ) ) {
$value = stripslashes( $value );
} elseif ( is_array( $value ) ) {
foreach ( $value as &$tmp ) {
$tmp = $this->recursive_stripslashes( $tmp );
}
} elseif ( is_object( $value ) ) {
foreach ( get_object_vars( $value ) as $key => $data ) {
$value->{$key} = $this->recursive_stripslashes( $data );
}
}
return $value;
}
/**
* Enqueues a NextGEN API task list action.
*
* @return array
*/
public function enqueue_nextgen_api_task_list_action() {
$api = $this->get_nextgen_api();
$user_obj = $this->authenticate_user();
$response = [];
if ( $user_obj != null && ! is_a( $user_obj, 'WP_Error' ) ) {
wp_set_current_user( $user_obj->ID );
// Use param_json() instead of param() for JSON fields: param() applies wp_unslash()/
// stripslashes() which converts \\ to \ in JSON strings, leaving invalid escape
// sequences (e.g. \U, \J) that cause json_decode() to return null on Windows paths.
$app_config = $this->param_json( 'app_config' );
$task_list = $this->param_json( 'task_list' );
$extra_data = $this->param_json( 'extra_data' ) ?? [];
if ( ! is_array( $extra_data ) ) {
$extra_data = [];
}
$upload_keys = $this->merge_uploaded_files( $extra_data );
if ( $task_list != null ) {
$task_count = count( $task_list );
$auth_count = 0;
$denied_caps = [];
foreach ( $task_list as &$task_item ) {
$task_name = isset( $task_item['name'] ) ? $task_item['name'] : null;
$task_type = isset( $task_item['type'] ) ? $task_item['type'] : null;
$task_query = isset( $task_item['query'] ) ? $task_item['query'] : null;
$task_auth = false;
// The capability each refused task was actually missing, so the response can
// say which one rather than naming a single capability for every refusal.
$task_denied_cap = null;
switch ( $task_type ) {
case 'gallery_add':
$task_auth = Security::is_allowed( 'nextgen_edit_gallery' );
$task_denied_cap = 'NextGEN Manage gallery';
break;
case 'gallery_list_get':
// Reads every gallery's title/description/preview via find_all() in
// handle_job(). It had no case here at all, so it was stamped 'forbid'
// and skipped - which the old $task_count == $auth_count gate at least
// surfaced as an outright error. Under the relaxed gate it would have
// become a silent no-result on a successful job.
$task_auth = Security::is_allowed( 'nextgen_edit_gallery' );
$task_denied_cap = 'NextGEN Manage gallery';
break;
case 'gallery_remove':
case 'gallery_edit':
$query_id = $api->get_query_id( $task_query['id'], $task_list );
$gallery = null;
// The old NextGEN XMLRPC API had this logic so replicating it here for safety.
if ( $query_id ) {
$gallery_mapper = GalleryMapper::get_instance();
$gallery = $gallery_mapper->find( $query_id );
}
/*
* The "NextGEN Manage gallery" capability is required in every case
* (#932), and ownership is never authorization on its own (#933): the
* stored author id outlives the capability being revoked, and a
* Subscriber who happens to own a gallery must not be able to edit it
* through this endpoint. Past that gate, ownership decides whether the
* additional "NextGEN Manage others gallery" capability is also needed.
*
* The author column is compared as an integer: the mapper returns it as
* a numeric string, so a strict comparison against the user id is false
* for every user.
*/
$task_auth = Security::is_allowed( 'nextgen_edit_gallery' );
$task_denied_cap = 'NextGEN Manage gallery';
if ( $gallery != null ) {
$is_owner = ( (int) \get_current_user_id() === (int) $gallery->author );
$task_auth = ( $task_auth
&& ( $is_owner || Security::is_allowed( 'nextgen_edit_gallery_unowned' ) ) );
// Which capability to name in the refusal (#933): an owner who is
// refused is missing the base capability, anyone else is missing the
// others-gallery one. Only reported when the base gate passed, since
// without it the base capability is what is missing.
if ( ! $is_owner && Security::is_allowed( 'nextgen_edit_gallery' ) ) {
$task_denied_cap = 'NextGEN Manage others gallery';
}
}
break;
case 'album_remove':
case 'album_edit':
case 'album_add':
$task_auth = Security::is_allowed( 'nextgen_edit_album' );
$task_denied_cap = 'NextGEN Edit album';
break;
case 'image_list_move':
// A no-op in handle_job(), so nothing is lost by leaving it unauthorized.
break;
}
if ( $task_auth ) {
++$auth_count;
} elseif ( null !== $task_denied_cap ) {
$denied_caps[ $task_denied_cap ] = true;
}
$task_item['auth'] = $task_auth ? 'allow' : 'forbid';
}
// Queue the job when at least one task is authorized, rather than requiring every
// task to be. Each task already carries its own 'auth' flag, and handle_job()
// re-reads it and skips anything that is not 'allow', so an unauthorized task
// drops only itself. Requiring $task_count == $auth_count meant one unauthorized
// task voided the whole publish - a Lightroom collection is submitted as a single
// task list, so a photographer lost the entire batch, images included, and the
// only thing the desktop client showed was the generic "Authorization Failed."
if ( $auth_count > 0 ) {
$job_id = $api->add_job(
[
'user' => $user_obj->ID,
'clientid' => $this->param( 'clientid' ),
],
$app_config,
$task_list
);
if ( $job_id != null ) {
$post_back = $api->get_job_post_back( $job_id );
// A handler URL with no token in it is refused by the routing gate and by
// the execute action, so the publish would sit in the queue until
// MAX_JOB_AGE expired it while the client was told 'ok'.
if ( empty( $post_back['token'] ) ) {
$api->remove_job( $job_id );
$api->log_failure( 'job_token_missing', 'NextGEN Gallery: Lightroom discarded job ' . wp_strip_all_tags( (string) $job_id, true ) . ' because it carried no post_back token.' );
return [
'result' => 'error',
'error' => [
'code' => API::ERR_JOB_NOT_ADDED,
'message' => __( 'Job execution failed.', 'nggallery' ),
],
];
}
$handler_delay = defined( 'NGG_API_JOB_HANDLER_DELAY' ) ? intval( NGG_API_JOB_HANDLER_DELAY ) : 0;
$handler_delay = $handler_delay > 0 ? $handler_delay : 30; /* in seconds */
$handler_maxsize = defined( 'NGG_API_JOB_HANDLER_MAXSIZE' ) ? intval( NGG_API_JOB_HANDLER_MAXSIZE ) : 0;
$handler_maxsize = $handler_maxsize > 0 ? $handler_maxsize : $this->get_max_upload_size(); /* in bytes */
$handler_maxfiles = $this->get_max_upload_files();
$response['result'] = 'ok';
$response['result_object'] = [
'job_id' => $job_id,
'job_post_back' => $post_back,
// The token travels in the handler URL because the client stores that URL
// verbatim and posts back to it, so shipped clients send the token with no
// client-side change. That does put a bearer credential in a query string,
// where access and proxy logs keep it (CWE-598) - so a site whose client
// posts job_post_back in the body instead can define
// NGG_API_TOKEN_IN_HANDLER_URL false and keep it out of the URL. The token
// is returned in job_post_back either way, and param() reads $_REQUEST, so
// the body form needs no server change.
'job_handler_url' => home_url( $this->get_job_handler_query( $post_back ) ),
'job_handler_delay' => $handler_delay,
'job_handler_maxsize' => $handler_maxsize,
'job_handler_maxfiles' => $handler_maxfiles,
];
// Name the refused tasks rather than letting them disappear. They are
// skipped individually at execution time, so without this the client is
// told the job was accepted and simply never hears about them again.
if ( $auth_count < $task_count ) {
$skipped = $task_count - $auth_count;
$response['result_object']['unauthorized_task_count'] = $skipped;
$response['warning'] = [
'code' => API::ERR_NOT_AUTHORIZED,
'message' => sprintf(
/* translators: 1: number of skipped tasks, 2: total number of tasks, 3: comma-separated capability names. */
__( '%1$s of %2$s tasks were skipped for lack of permission. Ask an administrator to grant your role %3$s under NextGEN Gallery > Settings > Roles.', 'nggallery' ),
number_format_i18n( $skipped ),
number_format_i18n( $task_count ),
self::describe_denied_caps( $denied_caps )
),
];
}
if ( ! defined( 'NGG_API_SUPPRESS_QUICK_EXECUTE' ) || NGG_API_SUPPRESS_QUICK_EXECUTE == false ) {
if ( ! $api->is_execution_locked() ) {
$this->start_locked_execute();
try {
$result = $api->handle_job( $job_id, $api->get_job_data( $job_id ), $app_config, $api->get_job_task_list( $job_id ), $extra_data, $upload_keys );
$response['result_object']['job_result'] = $api->get_job_task_list( $job_id );
if ( $result ) {
// everything was finished, remove job.
$api->remove_job( $job_id );
}
// \Throwable rather than \Exception, for the same reason as the
// executor: a PHP 8 TypeError out of handle_job() is an \Error.
} catch ( \Throwable $e ) {
// handle_job() records the upload failures it recognizes against
// the individual task. Anything escaping to here does not, so log
// it instead of discarding it - this is the quick-execute leg of
// the same job the executor runs, and it is where a failure of the
// capability re-check inside handle_job() would surface.
// Logged ungated: the record is an exception string, not
// caller-chosen keys, and a production install does not
// define WP_DEBUG, so a gate here means the failure is
// recorded nowhere at all.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom quick-execute failed: ' . wp_strip_all_tags( (string) $e->getMessage(), true ) );
// Revise the 'ok' set above: the job is still enqueued
// because remove_job() was skipped, so the caller has to
// learn the run failed and retry through job_handler_url
// rather than treat the publish as finished. Same code and
// message the executor leg reports for an escaped throwable.
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_JOB_NOT_ADDED,
'message' => __( 'Job execution failed.', 'nggallery' ),
];
}
$this->stop_locked_execute();
}
}
} else {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_JOB_NOT_ADDED,
'message' => __( 'Job could not be added.', 'nggallery' ),
];
}
} else {
// Name the missing capability rather than returning a bare
// "Authorization Failed.". Lightroom surfaces one generic line per photo, so a
// permissions refusal is indistinguishable from a host or network problem - and
// it is the one cause the photographer can actually act on. Which capability is
// missing depends on the task, so it is collected per task above rather than
// hard-coded here.
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_NOT_AUTHORIZED,
'message' => sprintf(
/* translators: %s: comma-separated capability names. */
__( 'Authorization failed: this account lacks the required NextGEN permission. Ask an administrator to grant your role %s under NextGEN Gallery > Settings > Roles.', 'nggallery' ),
self::describe_denied_caps( $denied_caps )
),
];
}
} else {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_NO_TASK_LIST,
'message' => __( 'No task list was specified.', 'nggallery' ),
];
}
} else {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_NOT_AUTHENTICATED,
'message' => __( 'Authentication Failed.', 'nggallery' ),
];
}
return $response;
}
/**
* Executes a NextGEN API task list action.
*
* @return array
*/
public function execute_nextgen_api_task_list_action() {
$api = $this->get_nextgen_api();
$job_list = $api->get_job_list();
$response = [];
// Authentication, first statement, as the three sibling actions do. A caller with
// real credentials is trusted for every job.
$user_obj = $this->authenticate_user();
$authenticated = ( null !== $user_obj && ! is_a( $user_obj, 'WP_Error' ) );
if ( $authenticated ) {
wp_set_current_user( $user_obj->ID );
}
// Anonymous by necessity: the desktop client posts to the job_handler_url it was
// given at enqueue time and sends no credentials of its own, so the gate is the
// job's post_back token rather than a user.
//
// The request cannot name a file to read: uploaded bytes come only from a verified
// $_FILES entry, and the FTP branch's staged path comes from the stored job and is
// contained - see is_staged_image_path().
//
// Pre-4.5.1 jobs are refused, not grandfathered: a pending pre-update job is
// exactly #1029's exploit precondition. Such a publish has to be re-run.
if ( is_array( $job_list ) ) {
$job_token = (string) ( $this->param( 'job_post_back' ) ?? '' );
$executable = [];
$refused = false;
$expired = false;
$not_authorized = false;
$cutoff = time() - API::MAX_JOB_AGE;
foreach ( $job_list as $key => $job ) {
$created = isset( $job['created'] ) ? (int) $job['created'] : 0;
// Age before token: a pre-4.5.1 job has no stamp and its token is the weak
// md5( uniqid() ). Enforced here and not only in prune_stale_jobs(), which
// needs an authenticated enqueue, so a leaked job_handler_url expires.
if ( $created <= 0 || $created <= $cutoff ) {
$expired = true;
continue;
}
$expected = isset( $job['post_back']['token'] ) ? (string) $job['post_back']['token'] : '';
// Credentials authorise only the caller's OWN jobs. authenticate_user() checks no
// capability, so trusting any authenticated account for every job let a
// Subscriber force another user's pending publish to run - reading and deleting
// that user's staged originals - because handle_job() then impersonates the job
// owner. The token rung still covers the credential-less post-back, so shipped
// clients are unaffected.
// Ownership is never authorization on its own (#933): the stored author id
// outlives the capability being revoked. So the credential rung requires the
// base NextGEN capability, and a non-owner additionally needs the
// others-gallery capability - the same model this file already uses at
// enqueue time.
$job_owner = isset( $job['data']['user'] ) ? (int) $job['data']['user'] : 0;
$owns_job = false;
if ( $authenticated ) {
$is_owner = $job_owner > 0 && $job_owner === (int) $user_obj->ID;
$owns_job = Security::is_allowed( 'nextgen_edit_gallery' )
&& ( $is_owner || Security::is_allowed( 'nextgen_edit_gallery_unowned' ) );
if ( ! $owns_job ) {
$not_authorized = true;
}
}
if ( $owns_job
|| ( '' !== $job_token && '' !== $expected && hash_equals( $expected, $job_token ) ) ) {
$executable[ $key ] = $job;
} else {
$refused = true;
}
}
// Refusals are logged even when another job ran, since otherwise a refusal is
// invisible and token probing leaves no trace. Rate limited because this
// endpoint is anonymous and a stale job makes $expired true on every request:
// without the bound, any caller could grow the error log at will.
if ( ( $expired || $refused || $not_authorized ) && ! get_transient( self::REFUSAL_LOG_THROTTLE ) ) {
set_transient( self::REFUSAL_LOG_THROTTLE, 1, 5 * MINUTE_IN_SECONDS );
// Every cause that applied, not just the first: a single stale job makes
// $expired true on every request to this endpoint, and a ternary chain let that
// permanently mask the token-mismatch signal this log exists to capture.
$causes = [];
if ( $expired ) {
$causes[] = 'queued before 4.5.1 or past MAX_JOB_AGE';
}
if ( $not_authorized ) {
$causes[] = 'the caller lacks the capability to run them';
}
if ( $refused ) {
$causes[] = '' === $job_token ? 'no post_back token was sent' : 'post_back token did not match';
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
'NextGEN Gallery: Lightroom refused ' . ( count( $job_list ) - count( $executable ) ) . ' job(s): '
. implode( '; ', $causes )
. '. Further refusals are not logged for 5 minutes.'
);
}
if ( empty( $executable ) && ( $refused || $expired || $not_authorized ) ) {
// Order matters: a request that failed the token check, or lacked the
// capability, is told that - expiry only speaks when it is the only cause, so a
// stale job in the list cannot turn an authentication failure into
// "publish it again".
if ( $not_authorized ) {
return [
'result' => 'error',
'error' => [
'code' => API::ERR_NOT_AUTHORIZED,
'message' => __( 'You do not have permission to run this publish.', 'nggallery' ),
],
];
}
if ( $refused && '' !== $job_token ) {
return [
'result' => 'error',
'error' => [
'code' => API::ERR_NOT_AUTHENTICATED,
'message' => __( 'Authentication Failed.', 'nggallery' ),
],
];
}
if ( $expired ) {
return [
'result' => 'error',
'error' => [
'code' => API::ERR_JOB_TOKEN_REQUIRED,
'message' => __( 'This publish can no longer be resumed - it was queued before the site was updated, or it has expired. Please publish it again.', 'nggallery' ),
],
];
}
return [
'result' => 'error',
'error' => [
'code' => API::ERR_NOT_AUTHENTICATED,
'message' => __( 'Authentication Failed.', 'nggallery' ),
],
];
}
$job_list = empty( $executable ) ? null : $executable;
}
if ( ! $authenticated && '' === (string) ( $this->param( 'job_post_back' ) ?? '' ) ) {
return [
'result' => 'error',
'error' => [
'code' => API::ERR_NOT_AUTHENTICATED,
'message' => __( 'Authentication Failed.', 'nggallery' ),
],
];
}
if ( $api->is_execution_locked() ) {
$response['result'] = 'ok';
$response['info'] = [
'code' => API::INFO_EXECUTION_LOCKED,
'message' => __( 'Job execution is locked.', 'nggallery' ),
];
} elseif ( $job_list != null ) {
$this->start_locked_execute();
// Declared outside the try: when these lived inside it, a throw before they
// were assigned left both undefined, so the `$done_count == $job_count`
// comparison below evaluated null == null and the endpoint reported
// "Job list is finished." for a run in which nothing executed.
$job_count = count( $job_list );
$done_count = 0;
$client_result = [];
$unpersisted_result = [];
$job_error = null;
try {
$extra_data = $this->param_json( 'extra_data' ) ?? [];
if ( ! is_array( $extra_data ) ) {
$extra_data = [];
}
$upload_keys = $this->merge_uploaded_files( $extra_data );
foreach ( $job_list as $job ) {
$job_id = $job['id'];
$job_data = $job['data'];
$result = $api->handle_job( $job_id, $job_data, $job['app_config'], $job['task_list'], $extra_data, $upload_keys );
// The clientid match is the normal channel, but a job whose results could not
// be persisted is returned regardless: remove_job() below deletes it, so this
// response is the only place those per-image errors still exist.
if ( $api->has_unpersisted_task_list( $job_id ) ) {
$unpersisted_result[ $job_id ] = $api->get_job_task_list( $job_id );
}
if ( $api->has_unpersisted_task_list( $job_id )
|| ( isset( $job_data['clientid'] ) && $job_data['clientid'] == $this->param( 'clientid' ) ) ) {
$client_result[ $job_id ] = $api->get_job_task_list( $job_id );
}
if ( $result ) {
++$done_count;
// everything was finished, remove job.
$api->remove_job( $job_id );
}
if ( $api->should_stop_execution() ) {
break;
}
}
// \Throwable, not \Exception: handle_job() calls count() and property
// assignments on values decoded from the request, so a PHP 8 TypeError -
// an \Error, which \Exception does not catch - is a realistic outcome and
// used to escape as an uncaught fatal.
} catch ( \Throwable $e ) {
// handle_job() catches the upload exceptions it knows about and records
// them per task. Anything reaching here is outside that set, so it is a
// genuine failure of the run and must not be discarded: task progress is
// persisted only after the task loop completes, so an escape here also
// loses every per-task status accumulated in this call.
$job_error = $e->getMessage();
// Recorded on production - this is the leg publishes actually run through - but
// bounded, because the endpoint is reachable without credentials and a
// repeatedly failing publish would otherwise grow the log without limit.
// log_once() still emits every occurrence under WP_DEBUG.
$api->log_failure(
// Keyed on where the failure came from, not on its message: the message can
// embed caller-supplied strings, so hashing it would let a caller with a
// valid token vary the key at will and defeat the bound this is here for -
// growing error_log and wp_options together. Class plus file:line still
// separates genuinely different failures, which is what the key is for.
'job_execution_' . md5( get_class( $e ) . ':' . basename( (string) $e->getFile() ) . ':' . $e->getLine() ),
'NextGEN Gallery: Lightroom job execution failed: ' . wp_strip_all_tags( (string) $job_error, true )
);
}
$this->stop_locked_execute();
if ( null !== $job_error ) {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_JOB_NOT_ADDED,
'message' => __( 'Job execution failed.', 'nggallery' ),
];
} elseif ( $done_count == $job_count ) {
$response['result'] = 'ok';
$response['info'] = [
'code' => API::INFO_JOB_LIST_FINISHED,
'message' => __( 'Job list is finished.', 'nggallery' ),
];
} else {
$response['result'] = 'ok';
$response['info'] = [
'code' => API::INFO_JOB_LIST_UNFINISHED,
'message' => __( 'Job list is unfinished.', 'nggallery' ),
];
}
// The suppression switch is a payload-size choice, but remove_job() has already
// deleted the persisted copy, so an unpersisted result set exists nowhere else -
// those are returned regardless.
if ( ! defined( 'NGG_API_SUPPRESS_QUICK_SUMMARY' ) || NGG_API_SUPPRESS_QUICK_SUMMARY == false ) {
$response['result_object'] = $client_result;
} elseif ( ! empty( $unpersisted_result ) ) {
$response['result_object'] = $unpersisted_result;
}
} else {
$response['result'] = 'ok';
$response['info'] = [
'code' => API::INFO_NO_JOB_LIST,
'message' => __( 'Job list is empty.', 'nggallery' ),
];
}
return $response;
}
/**
* Gets the NextGEN API path list action.
*
* @return array
*/
public function get_nextgen_api_path_list_action() {
$api = $this->get_nextgen_api();
$app_config = $this->param( 'app_config' );
$user_obj = $this->authenticate_user();
$response = [];
if ( $user_obj != null && ! is_a( $user_obj, 'WP_Error' ) ) {
wp_set_current_user( $user_obj->ID );
$ftp_method = isset( $app_config['ftp_method'] ) ? $app_config['ftp_method'] : 'ftp';
$creds = [
'connection_type' => $ftp_method == 'sftp' ? 'ssh' : 'ftp',
'hostname' => $app_config['ftp_host'],
'port' => $app_config['ftp_port'],
'username' => $app_config['ftp_user'],
'password' => $app_config['ftp_pass'],
];
require_once ABSPATH . 'wp-admin/includes/file.php';
$wp_filesystem = $api->create_filesystem_access( $creds );
$root_path = null;
$base_path = null;
$plugin_path = null;
if ( $wp_filesystem ) {
$root_path = $wp_filesystem->wp_content_dir();
$base_path = $wp_filesystem->abspath();
$plugin_path = $wp_filesystem->wp_plugins_dir();
} else {
// fallbacks when unable to connect, try to see if we know the path already.
$root_path = get_option( 'ngg_ftp_root_path' );
if ( defined( 'FTP_BASE' ) ) {
$base_path = FTP_BASE;
}
if ( $root_path == null && defined( 'FTP_CONTENT_DIR' ) ) {
$root_path = FTP_CONTENT_DIR;
}
if ( defined( 'FTP_PLUGIN_DIR' ) ) {
$plugin_path = FTP_PLUGIN_DIR;
}
if ( $base_path == null && $root_path != null ) {
$base_path = dirname( $root_path );
}
if ( $root_path == null && $base_path != null ) {
$root_path = rtrim( $base_path, '/\\' ) . '/wp-content/';
}
if ( $plugin_path == null && $base_path != null ) {
$plugin_path = rtrim( $base_path, '/\\' ) . '/wp-content/plugins/';
}
}
if ( $root_path != null ) {
$response['result'] = 'ok';
$response['result_object'] = [
'root_path' => $root_path,
'wp_content_path' => $root_path,
'wp_base_path' => $base_path,
'wp_plugin_path' => $plugin_path,
];
} elseif ( $wp_filesystem != null ) {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_FTP_NO_PATH,
'message' => __( 'Could not determine FTP path.', 'nggallery' ),
];
} else {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_FTP_NOT_CONNECTED,
'message' => __( 'Could not connect to FTP to determine path.', 'nggallery' ),
];
}
} else {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_NOT_AUTHENTICATED,
'message' => __( 'Authentication Failed.', 'nggallery' ),
];
}
return $response;
}
/**
* Gets the NextGEN API token action.
*
* @return array
*/
public function get_nextgen_api_token_action() {
$regen = $this->param( 'regenerate_token' ) ? true : false;
$user_obj = $this->authenticate_user( $regen );
$response = [];
if ( $user_obj != null ) {
$response['result'] = 'ok';
$response['result_object'] = [
'token' => get_user_meta( $user_obj->ID, 'nextgen_api_token', true ),
];
} else {
$response['result'] = 'error';
$response['error'] = [
'code' => API::ERR_NOT_AUTHENTICATED,
'message' => __( 'Authentication Failed.', 'nggallery' ),
];
}
return $response;
}
/**
* Builds the job handler query string.
*
* @param array $post_back
* @return string
*/
protected function get_job_handler_query( $post_back ): string {
$query = '?photocrati_ajax=1&action=execute_nextgen_api_task_list';
if ( defined( 'NGG_API_TOKEN_IN_HANDLER_URL' ) && ! NGG_API_TOKEN_IN_HANDLER_URL ) {
return $query;
}
return $query . '&job_post_back=' . rawurlencode( isset( $post_back['token'] ) ? $post_back['token'] : '' );
}
/**
* Merges this request's uploads into $extra_data and returns their keys.
*
* The keys are what tells handle_job() which $extra_data entries carry a real
* upload: everything else in that array is caller-supplied JSON and its
* 'tmp_name' must never be read from disk.
*
* @param array $extra_data Passed by reference; uploads are merged into it.
* @return string[]
*/
protected function merge_uploaded_files( array &$extra_data ): array {
$upload_keys = [];
foreach ( $_FILES as $key => $file ) {
if ( substr( $key, 0, strlen( 'file_data_' ) ) !== 'file_data_' ) {
continue;
}
$data_key = substr( $key, strlen( 'file_data_' ) );
if ( '' === $data_key ) {
continue;
}
$extra_data[ $data_key ] = $file;
$upload_keys[] = (string) $data_key;
}
return $upload_keys;
}
/**
* Gets the NextGEN API instance.
*
* @return API
*/
protected function get_nextgen_api() {
if ( is_null( $this->nextgen_api ) ) {
$this->nextgen_api = API::get_instance();
}
return $this->nextgen_api;
}
/**
* Authenticates a user for the API.
*
* @param bool $regenerate_token
* @return object|null
*/
protected function authenticate_user( $regenerate_token = false ) {
$api = $this->get_nextgen_api();
$username = $this->param( 'q' );
$password = $this->param( 'z' );
$token = $this->param( 'tok' );
return $api->authenticate_user( $username, $password, $token, $regenerate_token );
}
/**
* Gets the maximum upload size.
*
* @return int
*/
protected function get_max_upload_size() {
static $max_size = -1;
if ( $max_size < 0 ) {
$post_max_size = $this->parse_size( ini_get( 'post_max_size' ) );
if ( $post_max_size > 0 ) {
$max_size = $post_max_size;
}
$upload_max = $this->parse_size( ini_get( 'upload_max_filesize' ) );
if ( $upload_max > 0 && $upload_max < $max_size ) {
$max_size = $upload_max;
}
}
return $max_size;
}
/**
* Parses a size string to bytes.
*
* @param string $size
* @return int
*/
protected function parse_size( $size ) {
$unit = preg_replace( '/[^bkmgtpezy]/i', '', $size );
$size = preg_replace( '/[^0-9\.]/', '', $size );
if ( $unit ) {
return round( $size * pow( 1024, stripos( 'bkmgtpezy', $unit[0] ) ) );
} else {
return round( $size );
}
}
/**
* Gets the maximum number of upload files.
*
* @return int
*/
protected function get_max_upload_files() {
return intval( ini_get( 'max_file_uploads' ) );
}
/**
* Handles shutdown callback.
*/
public function do_shutdown() {
if ( $this->nextgen_api_locked ) {
$this->get_nextgen_api()->set_execution_locked( false );
}
}
/**
* Starts locked execution mode.
*/
protected function start_locked_execute() {
if ( ! $this->shutdown_registered ) {
\register_shutdown_function( [ $this, 'do_shutdown' ] );
$this->shutdown_registered = true;
}
$this->get_nextgen_api()->set_execution_locked( true );
$this->nextgen_api_locked = true;
}
/**
* Stops locked execution mode.
*/
protected function stop_locked_execute() {
$this->get_nextgen_api()->set_execution_locked( false );
$this->nextgen_api_locked = false;
}
/**
* Renders the capabilities that refused tasks were missing, for a client-facing message.
*
* Refusals in one task list can come from different capabilities - a gallery edit needs
* "NextGEN Manage gallery" (or "NextGEN Manage others gallery" for someone else's gallery),
* an album task needs "NextGEN Edit album" - so naming a single one would misdiagnose the
* others. Falls back to a generic phrase when nothing was recorded, which happens when the
* only refused tasks are types that need no capability.
*
* Must live in this class: both call sites are in enqueue_nextgen_api_task_list_action() and
* reach it through `self::`, which resolves to the defining class. It was first added below,
* inside `class API`, where `self::describe_denied_caps()` from here raised
* "Call to undefined method ...\Controller::describe_denied_caps()" on both refusal paths -
* a runtime resolution failure that `php -l` cannot see and no CI on this PR would have caught.
*
* @param array $denied_caps Capability name => true.
* @return string
*/
private static function describe_denied_caps( array $denied_caps ) {
$names = array_keys( $denied_caps );
if ( ! $names ) {
return __( 'the required NextGEN permissions', 'nggallery' );
}
sort( $names );
return '"' . implode( '", "', $names ) . '"';
}
}
// phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound
/**
* Lightroom API class.
*/
class API {
// NOTE: these constants' numeric values MUST remain the same, do NOT change the values.
const ERR_NO_TASK_LIST = 1001;
const ERR_NOT_AUTHENTICATED = 1002;
const ERR_NOT_AUTHORIZED = 1003;
const ERR_JOB_NOT_ADDED = 1004;
const ERR_JOB_TOKEN_REQUIRED = 1005;
/**
* How long a queued job stays executable.
*
* Enforced in execute_nextgen_api_task_list_action() as well as in
* prune_stale_jobs(), so it bounds the job's post_back token - and therefore a
* leaked job_handler_url - and not merely the size of the option.
*/
const MAX_JOB_AGE = WEEK_IN_SECONDS;
const ERR_FTP_NOT_AUTHENTICATED = 1101;
const ERR_FTP_NOT_CONNECTED = 1102;
const ERR_FTP_NO_PATH = 1103;
const INFO_NO_JOB_LIST = 6001;
const INFO_JOB_LIST_FINISHED = 6002;
const INFO_JOB_LIST_UNFINISHED = 6003;
const INFO_EXECUTION_LOCKED = 6004;
/**
* Instances cache.
*
* @var array
*/
public static $_instances = [];
/**
* Start time for execution tracking.
*
* @var int
*/
public $_start_time;
/**
* Gets an API instance.
*
* @param bool|string $context
* @return API
*/
public static function get_instance( $context = false ) {
if ( ! isset( self::$_instances[ $context ] ) ) {
self::$_instances[ $context ] = new API( $context );
}
return self::$_instances[ $context ];
}
/**
* Constructs the API instance.
*
* @param string|bool $context
*/
public function __construct( $context ) {
$this->_start_time = time();
}
/**
* Determines if execution should stop.
*
* @return bool
*/
public function should_stop_execution() {
$timeout = defined( 'NGG_API_JOB_HANDLER_TIMEOUT' ) ? intval( NGG_API_JOB_HANDLER_TIMEOUT ) : ( intval( ini_get( 'max_execution_time' ) ) - 3 );
$timeout = $timeout > 0 ? $timeout : 27; /* most hosts have a limit of 30 seconds execution time, so 27 should be a safe default */
return ( time() - $this->_start_time >= $timeout );
}
/**
* Checks if execution is locked.
*
* @return bool
*/
public function is_execution_locked() {
$lock_time = get_option( 'ngg_api_execution_lock', 0 );
if ( $lock_time == 0 ) {
return false;
}
$lock_max = defined( 'NGG_API_EXECUTION_LOCK_MAX' ) ? intval( NGG_API_EXECUTION_LOCK_MAX ) : 0;
$lock_max = $lock_max > 0 ? $lock_max : 60 * 5; /* if the lock is 5 minutes old assume something went wrong and the lock couldn't be unset */
$time_diff = time() - $lock_time;
if ( $time_diff > $lock_max ) {
return false;
}
return true;
}
/**
* Sets the execution locked state.
*
* @param bool $locked
*/
public function set_execution_locked( $locked ) {
if ( $locked ) {
update_option( 'ngg_api_execution_lock', time(), false );
} else {
update_option( 'ngg_api_execution_lock', 0, false );
}
}
/**
* Gets the job list.
*
* @return array|null
*/
public function get_job_list() {
return get_option( 'ngg_api_job_list' );
}
/**
* Generates a job's post_back token.
*
* PHP's random_bytes() throws rather than returning false when no randomness source
* reachable, and add_job() has no try/catch around it - unguarded it would kill the
* enqueue mid-output and hand the client a truncated body. Mirrors the ladder
* authenticate_user() already keeps.
*
* @return string 64 hex characters.
*/
protected static function generate_token() {
if ( function_exists( 'random_bytes' ) ) {
try {
return bin2hex( random_bytes( 32 ) );
} catch ( \Throwable $e ) {
// Recorded: all rungs return 64 hex chars, so a host on the weakest source
// would look healthy - and this token is the only credential here.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not use random_bytes() for a job token and fell back to a weaker source: ' . wp_strip_all_tags( (string) $e->getMessage(), true ) );
}
}
if ( function_exists( 'openssl_random_pseudo_bytes' ) ) {
// Inside try/catch because from PHP 8.0 this throws rather than returning false.
try {
$strong = false;
$bytes = openssl_random_pseudo_bytes( 32, $strong );
if ( false !== $bytes && $strong ) {
return bin2hex( $bytes );
}
} catch ( \Throwable $e ) {
$openssl_error = $e->getMessage();
}
}
// No CSPRNG: refuse rather than mint a guessable credential. wp_rand() cannot reach
// random_int() on such a host either, so it degrades to a seeded pool - and this token
// is the only credential on an endpoint that accepts no password. add_job() turns the
// empty string into a refused enqueue, which the client can report, instead of issuing
// a weak token nobody would know to distrust.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
'NextGEN Gallery: Lightroom could not generate a job token; no CSPRNG was available, so the publish was refused.'
. ( isset( $openssl_error ) ? ' openssl: ' . wp_strip_all_tags( (string) $openssl_error, true ) : '' )
);
return '';
}
/**
* Adds a job to the job list.
*
* @param array $job_data
* @param array $app_config
* @param array $task_list
* @return string|null
*/
public function add_job( $job_data, $app_config, $task_list ) {
$job_token = self::generate_token();
if ( '' === $job_token ) {
// generate_token() has already recorded why.
return null;
}
// Before the list is read, so the new job is not built on a pre-prune snapshot.
$this->prune_stale_jobs();
// get_job_list() returns false before the option exists, and "false[$id] = …" is
// deprecated in PHP 8.1 and an error in 9 - so the first publish on a site hit it.
$job_list = $this->get_job_list();
$job_list = is_array( $job_list ) ? $job_list : [];
$job_id = uniqid();
while ( isset( $job_list[ $job_id ] ) ) {
$job_id = uniqid();
}
$job = [
'id' => $job_id,
'post_back' => [
// Not md5( $job_id ): $job_id is uniqid(), which is a hex encoding of the
// current microtime with no entropy source, so deriving the token from it
// gives the token the identifier's ~20 bits rather than the hash's. This
// value is the only credential on the execute endpoint, so it comes from
// the CSPRNG. (Same defect class as envira-gallery-plugin#407.)
'token' => $job_token,
],
// Kept so a job's age is knowable. remove_job() runs only when a job
// completes, so without a timestamp there is nothing to reclaim an abandoned
// job by, and prune_stale_jobs() below would have nothing to work from.
'created' => time(),
'data' => $job_data,
'app_config' => $app_config,
'task_list' => $task_list,
];
$job_list[ $job_id ] = $job;
// Checked: the caller's contract is null on failure (ERR_JOB_NOT_ADDED), and returning
// an id for a job that was never stored told the photographer the publish was queued
// when nothing would ever run it.
if ( ! update_option( 'ngg_api_job_list', $job_list, false ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not store job ' . wp_strip_all_tags( (string) $job_id, true ) . '; the publish was not queued.' );
return null;
}
return $job_id;
}
/**
* Records a job-level warning on every task without an error, so a failure after the
* task loop still reaches the client. 'warning', not 'fatal': the images did arrive.
*
* @param array $task_list
* @param string $message
* @return array
*/
protected function record_job_warning( $task_list, $message ) {
if ( ! is_array( $task_list ) ) {
return $task_list;
}
foreach ( $task_list as &$task_item ) {
if ( ! is_array( $task_item ) || ! empty( $task_item['error'] ) ) {
continue;
}
$task_item['error'] = [
'level' => 'warning',
'message' => $message,
];
}
unset( $task_item );
return $task_list;
}
/**
* Drops jobs that can no longer be executed.
*
* Nothing else drains ngg_api_job_list - remove_job() runs only on completion - so
* pre-4.5.1 jobs (no 'created' stamp, refused at the execute endpoint) and jobs past
* MAX_JOB_AGE would otherwise stay forever.
*
* Called from add_job(), which is authenticated, so an anonymous caller can never
* trigger the cleanup.
*
* @return void
*/
protected function prune_stale_jobs() {
$job_list = $this->get_job_list();
if ( ! is_array( $job_list ) || empty( $job_list ) ) {
return;
}
$cutoff = time() - self::MAX_JOB_AGE;
$kept = [];
foreach ( $job_list as $key => $job ) {
$created = isset( $job['created'] ) ? (int) $job['created'] : 0;
if ( $created > $cutoff ) {
$kept[ $key ] = $job;
}
}
if ( count( $kept ) !== count( $job_list ) ) {
// Logged, like every other refusal this class makes. Dropping a job silently
// is the defect shape several earlier fixes on this file removed: the next
// request finds no job, falls through to "Job list is empty." and the caller
// is told success for a publish that will never complete.
$dropped = array_diff( array_keys( $job_list ), array_keys( $kept ) );
// After the write and only if it took: the value always differs here, so false
// is a real failure, and add_job() would write those jobs straight back.
if ( update_option( 'ngg_api_job_list', $kept, false ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom dropped ' . count( $dropped ) . ' job(s) that can no longer be executed (queued before 4.5.1, or older than ' . (int) self::MAX_JOB_AGE . ' seconds): ' . wp_strip_all_tags( implode( ', ', $dropped ), true ) );
} else {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not drop ' . count( $dropped ) . ' unexecutable job(s); they remain in the job list.' );
}
}
}
/**
* Updates a job in the job list.
*
* @param string $job_id
* @param array $job
* @return bool True when the job exists and the write took (or was a no-op).
*/
public function _update_job( $job_id, $job ) {
$job_list = $this->get_job_list();
if ( ! isset( $job_list[ $job_id ] ) ) {
return false;
}
if ( $job_list[ $job_id ] === $job ) {
// Nothing to write. update_option() reports false for an unchanged value, so
// without this a no-op update would be reported as a write failure.
return true;
}
$job_list[ $job_id ] = $job;
// Returned, not discarded: a caller persisting something the client must see needs
// to know whether the write landed.
return (bool) update_option( 'ngg_api_job_list', $job_list, false );
}
/**
* Removes a job from the job list.
*
* @param string $job_id
*/
public function remove_job( $job_id ) {
$job_list = $this->get_job_list();
if ( isset( $job_list[ $job_id ] ) ) {
unset( $job_list[ $job_id ] );
update_option( 'ngg_api_job_list', $job_list, false );
}
}
/**
* Task lists whose write did not land, keyed by job id, so the statuses still reach the
* caller. Bounded by the request: at most one entry per job executed in it.
*
* @var array
*/
protected $unpersisted_task_lists = [];
/**
* Whether this request holds task results for a job that could not be persisted.
*
* @param string $job_id
* @return bool
*/
public function has_unpersisted_task_list( $job_id ) {
return isset( $this->unpersisted_task_lists[ $job_id ] );
}
/**
* Gets a job by ID.
*
* @param string $job_id
* @return array|null
*/
public function get_job( $job_id ) {
$job_list = $this->get_job_list();
if ( isset( $job_list[ $job_id ] ) ) {
return $job_list[ $job_id ];
}
return null;
}
/**
* Gets job data by job ID.
*
* @param string $job_id
* @return array|null
*/
public function get_job_data( $job_id ) {
$job = $this->get_job( $job_id );
if ( $job != null ) {
return $job['data'];
}
return null;
}
/**
* Gets the task list for a job.
*
* @param string $job_id
* @return array|null
*/
public function get_job_task_list( $job_id ) {
// Prefer the in-memory list when a persist failed: both callers re-read this after
// handle_job() returns, so otherwise a refused write served them the stale pre-run
// list - every task 'pending', no per-image errors - while the response still
// reported the job finished.
if ( isset( $this->unpersisted_task_lists[ $job_id ] ) ) {
return $this->unpersisted_task_lists[ $job_id ];
}
$job = $this->get_job( $job_id );
if ( $job != null ) {
return $job['task_list'];
}
return null;
}
/**
* Sets the task list for a job.
*
* @param string $job_id
* @param array $task_list
* @return bool
*/
public function set_job_task_list( $job_id, $task_list ) {
$job = $this->get_job( $job_id );
if ( $job != null ) {
$job['task_list'] = $task_list;
return $this->_update_job( $job_id, $job );
}
return false;
}
/**
* Gets the post-back data for a job.
*
* @param string $job_id
* @return array|null
*/
public function get_job_post_back( $job_id ) {
$job = $this->get_job( $job_id );
if ( $job != null ) {
return $job['post_back'];
}
return null;
}
/**
* Authenticates a user for the API.
*
* @param string $username
* @param string $password
* @param string|null $token
* @param bool $regenerate_token
* @return object|null
*/
public function authenticate_user( $username, $password, $token, $regenerate_token = false ) {
$user_obj = null;
if ( $token != null ) {
$users = get_users(
[
'meta_key' => 'nextgen_api_token',
'meta_value' => $token,
]
);
if ( $users != null && count( $users ) > 0 ) {
$user_obj = $users[0];
}
}
if ( $user_obj == null ) {
if ( $username != null && $password != null ) {
$user_obj = wp_authenticate( $username, $password );
$token = get_user_meta( $user_obj->ID, 'nextgen_api_token', true );
if ( $token == null ) {
$regenerate_token = true;
}
}
}
if ( is_a( $user_obj, 'WP_Error' ) ) {
$user_obj = null;
}
if ( $regenerate_token ) {
if ( $user_obj != null ) {
$token = '';
if ( function_exists( 'random_bytes' ) ) {
$token = bin2hex( random_bytes( 16 ) );
} elseif ( function_exists( 'openssl_random_pseudo_bytes' ) ) {
$token = bin2hex( openssl_random_pseudo_bytes( 16 ) );
} else {
for ( $i = 0; $i < 16; $i++ ) {
$token .= bin2hex( wp_rand( 0, 15 ) );
}
}
update_user_meta( $user_obj->ID, 'nextgen_api_token', $token );
}
}
return $user_obj;
}
/**
* Creates filesystem access for FTP/SSH operations.
*
* @param array $args
* @param string|null $method
* @return object|false
*/
public function create_filesystem_access( $args, $method = null ) {
// taken from wp-admin/includes/file.php but with modifications.
if ( ! $method && isset( $args['connection_type'] ) && 'ssh' == $args['connection_type'] && extension_loaded( 'ssh2' ) && function_exists( 'stream_get_contents' ) ) {
$method = 'ssh2';
}
if ( ! $method && extension_loaded( 'ftp' ) ) {
$method = 'ftpext';
}
if ( ! $method && ( extension_loaded( 'sockets' ) || function_exists( 'fsockopen' ) ) ) {
$method = 'ftpsockets'; // Sockets: Socket extension; PHP Mode: FSockopen / fwrite / fread.
}
if ( ! $method ) {
return false;
}
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
if ( ! class_exists( "WP_Filesystem_$method" ) ) {
/**
* Filter the path for a specific filesystem method class file.
*
* @since 2.6.0
*
* @see get_filesystem_method()
*
* @param string $path Path to the specific filesystem method class file.
* @param string $method The filesystem method to use.
*/
$abstraction_file = apply_filters( 'filesystem_method_file', ABSPATH . 'wp-admin/includes/class-wp-filesystem-' . $method . '.php', $method );
if ( ! file_exists( $abstraction_file ) ) {
return false;
}
require_once $abstraction_file;
}
$method_class = "WP_Filesystem_$method";
$wp_filesystem = new $method_class( $args );
// Define the timeouts for the connections. Only available after the construct is called to allow for per-transport overriding of the default.
if ( ! defined( 'FS_CONNECT_TIMEOUT' ) ) {
define( 'FS_CONNECT_TIMEOUT', 30 );
}
if ( ! defined( 'FS_TIMEOUT' ) ) {
define( 'FS_TIMEOUT', 30 );
}
if ( is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->get_error_code() ) {
return false;
}
if ( ! $wp_filesystem->connect() ) {
if ( $method == 'ftpext' ) { // attempt connecting with alternative method.
return $this->create_filesystem_access( $args, 'ftpsockets' );
}
return false; // There was an error connecting to the server.
}
// Set the permission constants if not already set.
if ( ! defined( 'FS_CHMOD_DIR' ) ) {
define( 'FS_CHMOD_DIR', ( fileperms( ABSPATH ) & 0777 | 0755 ) );
}
if ( ! defined( 'FS_CHMOD_FILE' ) ) {
define( 'FS_CHMOD_FILE', ( fileperms( ABSPATH . 'index.php' ) & 0777 | 0644 ) );
}
return $wp_filesystem;
}
/**
* Returns an actual scalar ID based on parametric ID (e.g. a parametric ID could represent the query ID from another task).
*
* @param mixed $id
* @param array $task_list
* @return mixed
*/
public function get_query_id( $id, &$task_list ) {
$task_id = $id;
if ( is_object( $task_id ) || is_array( $task_id ) ) {
$id = null;
// it was specified that the query ID is referencing the query ID from another task.
if ( isset( $task_id['target'] ) && $task_id['target'] == 'task' ) {
if ( isset( $task_id['id'] ) && isset( $task_list[ $task_id['id'] ] ) ) {
$target_task = $task_list[ $task_id['id'] ];
if ( isset( $target_task['query']['id'] ) ) {
$id = $target_task['query']['id'];
}
}
}
}
return $id;
}
/**
* Returns an actual scalar ID based on parametric ID (e.g. a parametric ID could represent the resulting object ID from another task).
*
* @param mixed $id
* @param array $result_list
* @return mixed
*/
public function get_object_id( $id, &$result_list ) {
$task_id = $id;
if ( is_object( $task_id ) || is_array( $task_id ) ) {
$id = null;
// it was specified that the query ID is referencing the result from another task.
if ( isset( $task_id['target'] ) && $task_id['target'] == 'task' ) {
if ( isset( $task_id['id'] ) && isset( $result_list[ $task_id['id'] ] ) ) {
$target_result = $result_list[ $task_id['id'] ];
if ( isset( $target_result['object_id'] ) ) {
$id = $target_result['object_id'];
}
}
}
}
return $id;
}
/**
* Re-verifies at execution time that the job's user may act on a task.
*
* The `auth` flag each task carries was decided by
* enqueue_nextgen_api_task_list_action() and then persisted with the job in the
* `ngg_api_job_list` option. execute_nextgen_api_task_list_action() drains that
* option without authenticating the requester, and handle_job() impersonates the
* stored user, so a capability revoked between enqueue and execute would otherwise
* go unnoticed and the queued write would still run. Every task type that destroys
* a record, writes a file, or assigns entity properties calls this.
*
* @param string $capability Capability required in all cases.
* @param object|null $entity Entity being acted on, or null when there is none yet.
* @param string|null $owner_field Entity field holding the owner's user ID, when the
* entity type has one. Albums have no owner column.
* @param string|null $unowned_capability Capability that permits acting on someone
* else's record.
* @return bool
*/
protected function task_is_authorized( $capability, $entity = null, $owner_field = null, $unowned_capability = null ) {
if ( ! Security::is_allowed( $capability ) ) {
return false;
}
// Ownership only narrows; it never substitutes for the capability above.
if ( null === $entity || null === $owner_field || null === $unowned_capability ) {
return true;
}
if ( ! isset( $entity->{$owner_field} ) ) {
return true;
}
return \get_current_user_id() === (int) $entity->{$owner_field}
|| Security::is_allowed( $unowned_capability );
}
/**
* Finds an array entry by key and value.
*
* @param array $array_target
* @param string $entry_key
* @param mixed $entry_value
* @return int|string|null
*/
public function _array_find_by_entry( array $array_target, $entry_key, $entry_value ) {
foreach ( $array_target as $key => $value ) {
$item = $value;
if ( isset( $item[ $entry_key ] ) && $item[ $entry_key ] == $entry_value ) {
return $key;
}
}
return null;
}
/**
* Filters an array by entry key.
*
* @param array $array_target
* @param array $array_source
* @param string $entry_key
* @return array
*/
public function _array_filter_by_entry( array $array_target, array $array_source, $entry_key ) {
foreach ( $array_source as $key => $value ) {
$item = $value;
if ( isset( $item[ $entry_key ] ) ) {
$find_key = $this->_array_find_by_entry( $array_target, $entry_key, $item[ $entry_key ] );
if ( $find_key !== null ) {
unset( $array_target[ $find_key ] );
}
}
}
return $array_target;
}
/**
* Resolves an upload temp path that PHP reported relative.
*
* PHP's is_uploaded_file() compares against its own per-request registry by
* string, so it returns true for the relative name PHP recorded - but
* file_get_contents() would then resolve that name against the process working
* directory and miss. #460 is that failure, confirmed on a customer install where
* tmp_name was "tmp/phprXw5Dc".
*
* Several bases are tried because the failing configurations disagree: #460's
* confirmed host has an upload_tmp_dir outside the open_basedir jail, while its
* configuration B has ini_get() reporting a directory PHP did not write to. First
* readable candidate wins, and which one is logged.
*
* Only call this after is_uploaded_file() has accepted the original string. The
* input is checked with is_safe_path_string() before any candidate is built, and
* each candidate must be contained in the base it was built from - a realpath()
* pass, so a symlink planted in a world-writable temp directory cannot point the
* read outside it.
*
* @param string $tmp_name Path as PHP recorded it.
* @return string Absolute path where one was found, otherwise the input unchanged.
*/
protected function resolve_upload_tmp_path( string $tmp_name ): string {
$normalized = str_replace( '\\', '/', $tmp_name );
// Already absolute, POSIX or drive-letter.
if ( 0 === strpos( $normalized, '/' ) || 1 === preg_match( '#^[A-Za-z]:/#', $normalized ) ) {
return $tmp_name;
}
// The resolved path reaches a read, so it gets the same string rejections as
// every other path this class handles.
if ( ! $this->is_safe_path_string( $normalized ) ) {
return $tmp_name;
}
$upload_tmp_dir = ini_get( 'upload_tmp_dir' );
$relative = ltrim( $normalized, '/' );
$leaf = basename( $relative );
$candidates = [];
// Both join shapes: #460's host reports tmp_name as "tmp/phprXw5Dc" with an
// upload_tmp_dir of ".../tmp/", so joining the whole relative path gives
// ".../tmp/tmp/phprXw5Dc" and misses, while the basename against it is the file.
foreach ( [ $upload_tmp_dir, sys_get_temp_dir() ] as $base ) {
$candidates[] = [ $base, $relative ];
$candidates[] = [ $base, $leaf ];
}
// Last resort for jailed layouts, where the recorded name is relative to the web
// root rather than to a temp directory. There is deliberately no '/' candidate:
// Security::path_is_within() refuses any base that reduces to the filesystem
// root, so contain_path( $x, '/' ) is always null and such a candidate could
// never be reached.
$candidates[] = [ ABSPATH, $relative ];
foreach ( $candidates as list( $base, $suffix ) ) {
if ( ! is_string( $base ) || '' === trim( $base ) || '' === $suffix ) {
continue;
}
$candidate = rtrim( str_replace( '\\', '/', $base ), '/' ) . '/' . $suffix;
// Real containment. An earlier revision compared basenames, which is a
// tautology here - the candidate is the base plus that suffix - so it rejected
// nothing. contain_path()'s realpath() pass catches a planted symlink.
if ( null === Security::contain_path( $candidate, $base ) ) {
continue;
}
if ( is_readable( $candidate ) && is_file( $candidate ) ) {
// Gated: fires once per image, so a few hundred photos would be a line each.
// The failure below stays ungated - that is the record #460 needs.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom resolved the relative upload path "' . wp_strip_all_tags( $normalized, true ) . '" to "' . wp_strip_all_tags( $candidate, true ) . '".' );
}
return $candidate;
}
}
// Nothing resolved. Logged, because otherwise this is indistinguishable from
// "the path was already absolute" and the read failure that follows reports the
// same generic message #460 was filed about.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not resolve the relative upload path "' . wp_strip_all_tags( $normalized, true ) . '" against any known temporary directory.' );
return $tmp_name;
}
/**
* Names a PHP upload error code, so a rejected upload is distinguishable from a
* file that could not be read.
*
* #460 item 2: there was no UPLOAD_ERR_* reference anywhere in this integration,
* so UPLOAD_ERR_NO_TMP_DIR and UPLOAD_ERR_CANT_WRITE - both host-configuration
* faults with a clear remedy - surfaced as the same "Could not obtain data for
* image" string as every other cause.
*
* @param int $code One of the UPLOAD_ERR_* constants.
* @return string
*/
protected static function describe_upload_error( int $code ): string {
switch ( $code ) {
case UPLOAD_ERR_INI_SIZE:
return 'UPLOAD_ERR_INI_SIZE';
case UPLOAD_ERR_FORM_SIZE:
return 'UPLOAD_ERR_FORM_SIZE';
case UPLOAD_ERR_PARTIAL:
return 'UPLOAD_ERR_PARTIAL';
case UPLOAD_ERR_NO_FILE:
return 'UPLOAD_ERR_NO_FILE';
case UPLOAD_ERR_NO_TMP_DIR:
return 'UPLOAD_ERR_NO_TMP_DIR';
case UPLOAD_ERR_CANT_WRITE:
return 'UPLOAD_ERR_CANT_WRITE';
case UPLOAD_ERR_EXTENSION:
return 'UPLOAD_ERR_EXTENSION';
default:
return 'UPLOAD_ERR_' . $code;
}
}
/**
* Checks whether a byte string is a real image.
*
* PHP's getimagesizefromstring() reads the header only, so this does not depend on
* file existing locally - the FTP method hands us bytes, not a path.
*
* @param string $data
* @return bool
*/
protected function is_image_data( string $data, string $filename = '' ): bool {
$named = '' === $filename ? '(unnamed)' : wp_strip_all_tags( $filename, true );
if ( ! function_exists( 'getimagesizefromstring' ) ) {
// A host decision, not a bad file: every staged image is refused here, so it is
// recorded as such. Fails closed rather than ingesting and deleting an unknown file.
$this->log_once(
'no_getimagesize',
'NextGEN Gallery: Lightroom cannot verify image headers on this host (getimagesizefromstring is unavailable), so staged files are refused.'
);
return false;
}
$info = @getimagesizefromstring( $data ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! is_array( $info ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not read an image header from the staged bytes for ' . $named . ', so the file was left in place.' );
return false;
}
if ( empty( $info[0] ) || empty( $info[1] ) || empty( $info['mime'] )
|| 0 !== strpos( (string) $info['mime'], 'image/' ) ) {
// The header parsed but is not an image. Naming the detected type is the difference
// between "your file is broken" and "that is a PDF".
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom refused a staged file that is not an image for ' . $named . '; detected type: ' . wp_strip_all_tags( (string) ( $info['mime'] ?? 'unknown' ), true ) . '.' );
return false;
}
return true;
}
/**
* Public wrapper for log_once(), for the Controller's execution legs.
*
* @param string $key
* @param string $message
* @return void
*/
public function log_failure( $key, $message ) {
$this->log_once( $key, $message );
}
/**
* Records a message at most once per interval, and always under WP_DEBUG.
*
* Ungated is not unbounded: this endpoint is reachable without credentials, so a
* repeatedly failing publish could otherwise grow the error log without limit. Debug
* installs still get every occurrence.
*
* @param string $key Short identifier for the kind of message.
* @param string $message
* @return void
*/
protected function log_once( $key, $message ) {
$debug = defined( 'WP_DEBUG' ) && WP_DEBUG;
if ( ! $debug ) {
$transient = 'ngg_lr_log_' . md5( (string) $key );
if ( get_transient( $transient ) ) {
return;
}
set_transient( $transient, 1, 5 * MINUTE_IN_SECONDS );
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $message );
}
/**
* Rejects path strings that are never legitimate here, whatever filesystem they
* address: phar payloads, stream wrappers, traversal and NUL bytes.
*
* Separate from the containment checks below because it is the only part that
* applies to a remote (FTP/SFTP) path as well as a local one.
*
* @param string $path
* @return bool
*/
public function is_safe_path_string( string $path ): bool {
$path = str_replace( '\\', '/', $path );
if ( '' === trim( $path ) ) {
return false;
}
// Reject NUL bytes: they truncate the path inside the underlying C calls.
if ( false !== strpos( $path, "\0" ) ) {
return false;
}
// Do not allow phar:// streams, and block ".phar" filenames as well.
if ( false !== strpos( $path, '.phar' ) || false !== strpos( $path, 'phar://' ) ) {
return false;
}
// Also block all streams for good measure.
if ( false !== strpos( $path, '://' ) ) {
return false;
}
// And prevent all "../".
if ( false !== strpos( $path, '../' ) ) {
return false;
}
return true;
}
/**
* Checks whether a local path is one this integration may read or write.
*
* Fails closed: the path must sit in a directory the caller does not choose. The old
* implementation assigned strstr( $filename, '/tmp' ) back to $filename, so on a host
* whose temp dir is /tmp any path without "/tmp" in it became false, the containment
* test was skipped, and every other absolute path passed (#1029).
*
* The gallery document root alone would not be enough: NGG_GALLERY_ROOT_TYPE defaults
* to 'site', so that root is ABSPATH, which holds wp-config.php.
*
* Containment uses Util\Security::contain_path() for its realpath() pass, so a symlink
* planted in an allowed directory cannot point out of it.
*
* Local paths only - is_staged_image_path() handles the remote (FTP) case.
*
* @param string $filename
* @return bool
*/
public function is_valid_filename( string $filename ): bool {
if ( ! $this->is_safe_path_string( $filename ) ) {
return false;
}
$filename = str_replace( '\\', '/', $filename );
$filename = $this->normalize_bitnami_path( $filename );
// Denied before allowed: the code and translation directories sit inside wp-content,
// so the allow-list admits them - and a plugin's or theme's .png is a real image,
// which means it would otherwise pass the image-bytes check and be republished into a
// public gallery and then deleted. Credit to #1032 for this one.
if ( $this->is_code_directory_path( $filename ) ) {
return false;
}
$directories = array_map( [ $this, 'normalize_bitnami_path' ], $this->get_allowed_directories() );
foreach ( $directories as $directory ) {
if ( null !== Security::contain_path( $filename, $directory ) ) {
return true;
}
}
// Windows only. contain_path() compares case-sensitively, but Windows paths are
// not, so a staging path whose casing differs from WP_CONTENT_DIR - the client
// supplies full_path - would be refused for a difference the OS does not make.
// This costs no ground on POSIX, where the loop above is the whole answer.
if ( '\\' === DIRECTORY_SEPARATOR ) {
foreach ( $directories as $directory ) {
if ( $this->is_path_within( $filename, $directory ) ) {
return true;
}
}
}
return false;
}
/**
* Applies the Bitnami path rewrite.
*
* Bitnami stores files under /opt/bitnami but PHP can report the pre-symlink /bitnami
* form, which used to make containment reject legitimate files (87543794). It has to be
* applied to both sides of the comparison: contain_path()'s first pass is lexical, so a
* miss returns before the realpath() pass that would reconcile the symlink, and
* rewriting only the subject would refuse a pair that should match.
*
* @param string $path
* @return string
*/
protected function normalize_bitnami_path( $path ): string {
if ( ! is_string( $path ) ) {
return '';
}
return 0 === strpos( $path, '/bitnami' ) ? '/opt' . $path : $path;
}
/**
* Gets the local directories this integration may read from and write to.
*
* PHP's upload temp directory is deliberately absent: the only place upload
* bytes are read is the $_FILES branch of handle_job(), which asks
* is_uploaded_file() instead - PHP's own authoritative answer - so whitelisting
* the temp directory here would only widen the FTP branch for no gain.
*
* @return string[]
*/
protected function get_allowed_directories(): array {
return $this->collect_allowed_directories();
}
/**
* Whether a path is inside - or is - a directory holding code or translations.
*
* Nothing Lightroom does has any business reading or deleting there. Each base is
* compared in its reported and resolved spellings, for the same reason
* collect_allowed_directories() does.
*
* @param string $path
* @return bool
*/
protected function is_code_directory_path( string $path ): bool {
// The same sources DataStorage\Manager builds its protected_trees from, because a
// thinner third copy of that policy is how a gap gets in: WP_LANG_DIR rather than a
// hardcoded wp-content/languages (core always defines it, and a site can relocate it),
// and the template and stylesheet directories as well as the theme root, so a theme
// tree under a root added with register_theme_directory() is covered too.
$fs = Filesystem::get_instance();
$denied = [
$fs->get_document_root( 'plugins' ),
$fs->get_document_root( 'plugins_mu' ),
$fs->get_document_root( 'templates' ),
$fs->get_document_root( 'stylesheets' ),
defined( 'WP_LANG_DIR' ) ? WP_LANG_DIR : WP_CONTENT_DIR . '/languages',
];
if ( function_exists( 'get_theme_root' ) ) {
$denied[] = get_theme_root();
}
if ( ! empty( $GLOBALS['wp_theme_directories'] ) && is_array( $GLOBALS['wp_theme_directories'] ) ) {
foreach ( $GLOBALS['wp_theme_directories'] as $theme_dir ) {
$denied[] = $theme_dir;
}
}
// Both sides resolved, not just the bases. The allow-list below resolves the subject
// through Security::contain_path(), so testing the subject only lexically here left a
// symlink under wp-content pointing into a plugin or theme directory missed by this
// gate and then admitted by that one - the reported-versus-resolved asymmetry this
// containment code has already been corrected for twice.
$subjects = $this->resolve_path_forms( $path );
foreach ( $denied as $base ) {
if ( ! is_string( $base ) || '' === trim( $base ) ) {
continue;
}
foreach ( $this->resolve_path_forms( $base ) as $form ) {
$form_prefix = rtrim( $form, '/' ) . '/';
foreach ( $subjects as $subject ) {
// Equality as well as containment, so a staging folder that *is* one of
// these directories is refused, not just a file beneath it.
//
// Compared case-insensitively, unconditionally. On a case-insensitive mount
// - macOS, a CIFS/NTFS bind mount, Docker Desktop over APFS - the OS opens
// wp-content/PLUGINS/... as the real plugins directory while realpath()
// hands the requested spelling straight back, so resolving the path is not
// enough on its own. This is the deny side, so comparing too eagerly can
// only refuse a staging folder that differs from a code directory by case
// alone, which is not a layout anything legitimate uses.
if ( $this->is_path_within( $subject, $form )
|| rtrim( $subject, '/' ) === rtrim( $form, '/' )
|| 0 === strncasecmp( $subject, $form_prefix, strlen( $form_prefix ) )
|| 0 === strcasecmp( rtrim( $subject, '/' ), rtrim( $form, '/' ) ) ) {
return true;
}
}
}
}
return false;
}
/**
* A path in every spelling it could be compared under: normalised, Bitnami-rewritten,
* and resolved.
*
* When the path itself does not exist - a staged file already consumed, a folder about to
* be written - the deepest existing ancestor is resolved and the remainder re-attached,
* so a symlinked parent is still reconciled.
*
* @param string $path
* @return string[]
*/
protected function resolve_path_forms( string $path ): array {
$normalized = $this->normalize_path_for_compare( $this->normalize_bitnami_path( wp_normalize_path( $path ) ) );
$forms = [ $normalized ];
$real = @realpath( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( is_string( $real ) && '' !== $real ) {
$forms[] = $this->normalize_path_for_compare( $this->normalize_bitnami_path( wp_normalize_path( $real ) ) );
return array_values( array_unique( $forms ) );
}
// Walk up to the deepest ancestor that does exist.
$segments = explode( '/', $normalized );
$remaining = count( $segments );
$tail = [];
while ( $remaining > 1 ) {
$tail[] = array_pop( $segments );
--$remaining;
$probe = implode( '/', $segments );
if ( '' === $probe || '/' === $probe ) {
break;
}
$resolved = @realpath( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( is_string( $resolved ) && '' !== $resolved ) {
$forms[] = $this->normalize_path_for_compare(
$this->normalize_bitnami_path( wp_normalize_path( $resolved ) ) . '/' . implode( '/', array_reverse( $tail ) )
);
break;
}
}
return array_values( array_unique( $forms ) );
}
/**
* Builds the allowed-directory list.
*
* @return string[]
*/
protected function collect_allowed_directories(): array {
$directories = [
// The FTP upload method stages files under wp-content.
WP_CONTENT_DIR,
];
// The gallery storage directory is normally inside wp-content, but the
// gallerypath setting can place it elsewhere.
//
// Not wrapped in try/catch: get_upload_abspath() has no throw site, so a catch
// here would be dead code whose only effect is to make a narrowed allow-list look
// handled. The array_filter() below drops an empty return.
$directories[] = StorageManager::get_instance()->get_upload_abspath();
// Each base is also offered in its realpath() form, because contain_path() rejects on
// its lexical pass before it ever calls realpath() - so a host whose reported paths
// differ from their resolved ones (a symlinked wp-content or WordPress root, Plesk,
// cPanel, a Docker bind mount, any Bitnami layout other than the hardcoded /bitnami
// spelling) was refused for a difference that is not a containment failure.
//
// Expanded BEFORE the ancestor check below, never after: an earlier revision appended
// these afterwards, and on a symlinked root a gallerypath of '../' resolved to a
// directory above the real root that was lexically unrelated to ABSPATH - so it passed
// the filter and re-admitted wp-config.php by the resolved spelling.
foreach ( $directories as $directory ) {
if ( ! is_string( $directory ) || '' === trim( $directory ) ) {
continue;
}
$real = @realpath( $directory ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( is_string( $real ) && '' !== $real ) {
$directories[] = wp_normalize_path( $real );
continue;
}
// realpath() returns false exactly when the target crosses an open_basedir
// boundary - the same condition that makes reported and resolved paths differ, so
// this repair failing is worth a line on the hosts it was written for.
$this->log_once(
'unresolved_base_' . md5( (string) $directory ),
'NextGEN Gallery: Lightroom could not resolve an allowed directory, so containment uses its reported form only: ' . wp_strip_all_tags( (string) $directory, true )
);
}
$directories = array_unique( $directories );
// The WordPress root in every spelling a base could be compared against: reported,
// resolved, and Bitnami-rewritten.
$wp_roots = [ $this->normalize_bitnami_path( wp_normalize_path( ABSPATH ) ) ];
$real_root = @realpath( ABSPATH ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( is_string( $real_root ) && '' !== $real_root ) {
$wp_roots[] = $this->normalize_bitnami_path( wp_normalize_path( $real_root ) );
}
return array_values(
array_filter(
$directories,
function ( $directory ) use ( $wp_roots ) {
if ( ! is_string( $directory ) || '' === trim( $directory ) ) {
return false;
}
$directory = $this->normalize_bitnami_path( $directory );
// Never allow a directory that contains the WordPress root, in either
// spelling. gallerypath is admin-settable, and '../' or '/' resolves the
// gallery root to an ancestor of ABSPATH, which would re-admit
// wp-config.php.
//
// Tested with the lexical comparison, not contain_path(): that returns null
// the moment its lexical pass misses, and null here would mean "keep" - so a
// resolved base that is an ancestor of the reported root passed the check it
// was supposed to fail.
foreach ( $wp_roots as $root ) {
if ( $this->is_path_within( $root, $directory ) || rtrim( $root, '/' ) === rtrim( $directory, '/' ) ) {
return false;
}
}
return true;
}
)
);
}
/**
* Checks whether a staged image path may be read and deleted.
*
* Two boundaries, because the filesystem methods differ on where the file is:
*
* - 'direct': local, so it must sit in a directory the caller did not choose
* (is_valid_filename()). Containment against $images_folder alone would be
* self-referential - that folder is built from request JSON.
* - FTP/SFTP: the path is on the remote account, where no local root and no realpath()
* apply, so the bound is the job's staging folder plus the string rejections.
*
* What this does NOT close: on 'direct' an enqueuer can still steer the read-and-delete
* at any file under wp-content, and on FTP/SFTP the staging folder is caller-configured,
* so the bound is on the suffix, not the location (same for the status write). Both need
* a server-side staging root - #1003.
*
* @param string|null $image_path
* @param string $images_folder
* @param object $wp_fs WP_Filesystem instance.
* @return bool
*/
protected function is_staged_image_path( $image_path, $images_folder, $wp_fs ): bool {
if ( ! is_string( $image_path ) || ! $this->is_safe_path_string( $image_path ) ) {
return false;
}
// The file has to sit in this job's own staging folder. Necessary but not
// sufficient on a local filesystem - see the note above.
if ( ! $this->is_path_within( $image_path, $images_folder ) ) {
return false;
}
if ( ! is_object( $wp_fs ) || ! isset( $wp_fs->method ) || 'direct' !== $wp_fs->method ) {
return true;
}
return $this->is_valid_filename( $image_path );
}
/**
* Checks whether a path may be written to or removed.
*
* Used for the staging directory that rmdir() removes and for the job status file.
* Neither had a containment check of any kind: rmdir()'s only preconditions were
* that $storage_path and $path_prefix were non-empty and not a bare separator, and
* both are request-supplied.
*
* Same direct/remote split as is_staged_image_path(), and the same limitation: on
* a remote filesystem this is only the string filter, so a caller who can enqueue
* still chooses the remote directory written to, bounded by the FTP account's own
* scope. See the note on is_staged_image_path() - the write side of #1003 is open
* for the same reason as the read side, and is not closed by this method.
*
* @param string $path Directory or file path.
* @param object $wp_fs WP_Filesystem instance.
* @return bool
*/
protected function is_permitted_fs_path( $path, $wp_fs ): bool {
if ( ! is_string( $path ) || ! $this->is_safe_path_string( $path ) ) {
return false;
}
if ( ! is_object( $wp_fs ) || ! isset( $wp_fs->method ) || 'direct' !== $wp_fs->method ) {
return true;
}
return $this->is_valid_filename( $path );
}
/**
* Checks whether a path points to a location inside a directory.
*
* Lexical, so it is meaningful for a remote (FTP/SFTP) path as well as a local
* one. Local paths get Security::contain_path() on top, which additionally
* resolves symlinks; see is_staged_image_path().
*
* @param string $path
* @param string $directory
* @return bool
*/
public function is_path_within( string $path, string $directory ): bool {
$path = $this->normalize_path_for_compare( $path );
$directory = $this->normalize_path_for_compare( $directory );
if ( '' === $path || '' === $directory || '/' === $directory ) {
return false;
}
$directory = rtrim( $directory, '/' ) . '/';
// Windows paths are case-insensitive; POSIX paths are not.
if ( '\\' === DIRECTORY_SEPARATOR ) {
return 0 === strncasecmp( $path, $directory, strlen( $directory ) );
}
return 0 === strncmp( $path, $directory, strlen( $directory ) );
}
/**
* Normalises a path for prefix comparison: forward slashes, no "." or ".."
* segments, no repeated separators, no trailing separator.
*
* @param string $path
* @return string
*/
protected function normalize_path_for_compare( string $path ): string {
$path = str_replace( '\\', '/', trim( $path ) );
if ( '' === $path ) {
return '';
}
$is_absolute = 0 === strpos( $path, '/' );
$segments = [];
foreach ( explode( '/', $path ) as $segment ) {
if ( '' === $segment || '.' === $segment ) {
continue;
}
if ( '..' === $segment ) {
array_pop( $segments );
continue;
}
$segments[] = $segment;
}
$normalized = implode( '/', $segments );
return $is_absolute ? '/' . $normalized : $normalized;
}
/**
* Handles a job execution.
*
* Note: handle_job only worries about processing the job, it does NOT remove finished jobs anymore, the responsibility is on the caller to remove the job when handle_job returns true, this is to allow calling get_job_*() methods after handle_job has been called.
*
* @param string $job_id
* @param array $job_data
* @param array $app_config
* @param array $task_list
* @param array|null $extra_data
* @param string[] $upload_keys $extra_data keys whose value came from $_FILES in this
* request. Only those may be read from disk by path.
* @return bool
*/
public function handle_job( $job_id, $job_data, $app_config, $task_list, $extra_data = null, $upload_keys = [] ) {
$upload_keys = is_array( $upload_keys ) ? array_map( 'strval', $upload_keys ) : [];
$job_user = $job_data['user'];
$task_count = count( $task_list );
$done_count = 0;
$skip_count = 0;
$task_list_result = [];
wp_set_current_user( $job_user );
// Prevent PHP warnings about accessing undefined array keys.
$app_config['ftp_path'] = isset( $app_config['ftp_path'] ) ? $app_config['ftp_path'] : '';
$app_config['full_path'] = isset( $app_config['full_path'] ) ? $app_config['full_path'] : '';
/*
This block does all of the filesystem magic:
* - determines web paths based on FTP paths
* - initializes the WP_Filesystem mechanism in case this host doesn't support direct file access
* (this might not be 100% reliable right now due to NG core not making use of WP_Filesystem)
*/
// $ftp_path is assumed to be WP_CONTENT_DIR as accessed through the FTP mount point.
$ftp_path = rtrim( $app_config['ftp_path'], '/\\' );
$full_path = rtrim( $app_config['full_path'], '/\\' );
$root_path = rtrim( WP_CONTENT_DIR, '/\\' );
$creds = true; // WP_Filesystem(true) requests direct filesystem access.
$fs_sep = DIRECTORY_SEPARATOR;
$wp_fs = null;
require_once ABSPATH . 'wp-admin/includes/file.php';
if ( get_filesystem_method() !== 'direct' ) {
$fs_sep = '/';
$ftp_method = isset( $app_config['ftp_method'] ) ? $app_config['ftp_method'] : 'ftp';
$creds = [
'connection_type' => $ftp_method == 'sftp' ? 'ssh' : 'ftp',
'hostname' => $app_config['ftp_host'],
'port' => $app_config['ftp_port'],
'username' => $app_config['ftp_user'],
'password' => $app_config['ftp_pass'],
];
}
if ( WP_Filesystem( $creds ) ) {
$wp_fs = $GLOBALS['wp_filesystem'];
$path_prefix = $full_path;
if ( $wp_fs->method === 'direct' ) {
if ( trim( $ftp_path, " \t\n\r\x0B\\" ) == '' ) {
// Note: if ftp_path is empty, we assume the FTP account home dir is on wp-content.
$path_prefix = $root_path . $full_path;
} else {
$path_prefix = str_replace( $ftp_path, $root_path, $full_path );
}
}
} else {
include_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
if ( ! $wp_fs ) {
$wp_fs = new \WP_Filesystem_Direct( $creds );
}
}
foreach ( $task_list as &$task_item ) {
$task_id = isset( $task_item['id'] ) ? $task_item['id'] : null;
$task_name = isset( $task_item['name'] ) ? $task_item['name'] : null;
$task_type = isset( $task_item['type'] ) ? $task_item['type'] : null;
$task_auth = isset( $task_item['auth'] ) ? $task_item['auth'] : null;
$task_query = isset( $task_item['query'] ) ? $task_item['query'] : null;
$task_object = isset( $task_item['object'] ) ? $task_item['object'] : null;
$task_status = isset( $task_item['status'] ) ? $task_item['status'] : null;
$task_result = isset( $task_item['result'] ) ? $task_item['result'] : null;
// Reset per-task, not just where it is used. PHP locals persist across foreach
// iterations, and the gallery_edit branch assigns this only on the path where a
// gallery was resolved. Any branch that skips that assignment - a gallery that
// was not found, or an authorization refusal - would otherwise read the value
// left behind by an earlier task and, if that task had a chunked image list,
// rewrite this task's terminal 'error' status to 'unfinished', so the job was
// never removed and the refused task was re-executed indefinitely.
$image_list_unfinished = false;
// make sure we don't repeat execution of already finished tasks.
if ( $task_status == 'done' ) {
++$done_count;
// for previously finished tasks, store the result as it may be needed by future tasks.
if ( $task_id != null && $task_result != null ) {
$task_list_result[ $task_id ] = $task_result;
}
continue;
}
// make sure only valid and authorized tasks are executed.
if ( $task_status == 'error' || $task_auth != 'allow' ) {
++$skip_count;
continue;
}
// the task query ID can be a simple (integer) ID or more complex ID that gets converted to a simple ID, for instance to point to an object that is the result of a previously finished task.
if ( isset( $task_query['id'] ) ) {
$task_query['id'] = $this->get_object_id( $task_query['id'], $task_list_result );
}
$task_error = null;
switch ( $task_type ) {
case 'gallery_add':
if ( ! $this->task_is_authorized( 'nextgen_edit_gallery' ) ) {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => __( 'Not authorized to create a gallery.', 'nggallery' ),
];
break;
}
$mapper = GalleryMapper::get_instance();
$gallery = null;
$gal_errors = '';
if ( isset( $task_query['id'] ) ) {
$gallery = $mapper->find( $task_query['id'], true );
}
if ( $gallery == null ) {
$title = isset( $task_object['title'] ) ? $task_object['title'] : '';
$gallery = $mapper->create( [ 'title' => $title ] );
if ( ! $gallery || ! $gallery->save() ) {
if ( $gallery != null ) {
$gal_errors = $gallery->validation();
if ( is_array( $gal_errors ) ) {
$gal_errors = ' [' . wp_json_encode( $gal_errors ) . ']';
}
}
$gallery = null;
}
}
if ( $gallery != null ) {
$task_status = 'done';
$task_result['object_id'] = $gallery->id();
} else {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
/* translators: 1: gallery title, 2: error details */
'message' => sprintf( __( 'Gallery creation failed for "%1$s"%2$s.', 'nggallery' ), $title, $gal_errors ),
];
}
break;
case 'gallery_remove':
case 'gallery_edit':
if ( isset( $task_query['id'] ) ) {
$mapper = GalleryMapper::get_instance();
$gallery = $mapper->find( $task_query['id'], true );
$error = null;
$warning = null;
if ( ! $this->task_is_authorized( 'nextgen_edit_gallery', $gallery, 'author', 'nextgen_edit_gallery_unowned' ) ) {
/* translators: %1$s: gallery ID */
$error = __( 'Not authorized to edit gallery (%1$s).', 'nggallery' );
$gallery = null;
}
if ( $gallery != null ) {
if ( $task_type == 'gallery_remove' ) {
/**
* Gallery mapper instance.
*
* @var GalleryMapper $mapper.
*/
if ( ! $mapper->destroy( $gallery, true ) ) {
/* translators: %1$s: gallery ID */
$error = __( 'Failed to remove gallery (%1$s).', 'nggallery' );
}
} elseif ( $task_type == 'gallery_edit' ) {
if ( isset( $task_object['name'] ) ) {
$gallery->name = $task_object['name'];
}
if ( isset( $task_object['title'] ) ) {
$gallery->title = $task_object['title'];
}
if ( isset( $task_object['description'] ) ) {
$gallery->galdesc = $task_object['description'];
}
if ( isset( $task_object['preview_image'] ) ) {
$gallery->previewpic = $task_object['preview_image'];
}
if ( isset( $task_object['property_list'] ) ) {
$properties = $task_object['property_list'];
// Only descriptive schema fields may be assigned: 'path' is the
// directory every image path in the gallery is built from and
// 'author' decides who may manage it.
$assignable = MassAssignment::filter( $properties, 'gallery', $refused );
foreach ( $assignable as $key => $value ) {
$gallery->$key = $value;
}
if ( $refused ) {
// Reported as a warning on a 'done' task, not through $error:
// $error puts the task in the 'error' status with level 'fatal',
// which the Lightroom client turns into a failed publish for the
// whole collection, and it would also suppress the save-failure
// report further down. A refused key is not a failed publish.
// The image list is deliberately still processed - an
// unknown property key is no reason to discard uploads.
// Concatenated, not sprintf()'d, because $warning is itself a
// format string that gets the id interpolated further down:
// an inner sprintf() would undo describe_refused()'s percent
// doubling and the outer call would then eat a literal "%"
// coming from a request-supplied key.
/* translators: %1$s: gallery ID. The refused property names are appended. */
$warning = __( 'Gallery (%1$s) was saved, but these properties may not be set from Lightroom and were ignored: ', 'nggallery' )
. MassAssignment::describe_refused( $refused );
}
}
// Used to determine whether the task is complete. Reset once per task
// in the loop preamble above, so every branch reads a defined value.
if ( isset( $task_object['image_list'] ) && $wp_fs != null ) {
$storage_path = isset( $task_object['storage_path'] ) ? $task_object['storage_path'] : null;
$storage_path = trim( $storage_path, '/\\' );
$storage = StorageManager::get_instance();
$image_mapper = ImageMapper::get_instance();
$creds = true;
$images_folder = $path_prefix . $fs_sep . $storage_path . $fs_sep;
$images_folder = str_replace( [ '\\', '/' ], $fs_sep, $images_folder );
$images = $task_object['image_list'];
$result_images = isset( $task_result['image_list'] ) ? $task_result['image_list'] : [];
$images_todo = array_values( $this->_array_filter_by_entry( $images, $result_images, 'localId' ) );
$image_count = count( $images );
$result_image_count = count( $result_images );
foreach ( $images_todo as $image_index => $image ) {
$image_id = isset( $image['id'] ) ? $image['id'] : null;
$image_filename = isset( $image['filename'] ) ? $image['filename'] : null;
$image_path = isset( $image['path'] ) ? $image['path'] : null;
$image_data_key = isset( $image['data_key'] ) ? $image['data_key'] : null;
$image_action = isset( $image['action'] ) ? $image['action'] : null;
$image_status = isset( $image['status'] ) ? $image['status'] : 'skip';
if ( $image_filename == null ) {
$image_filename = basename( $image_path );
}
$ngg_image = $image_mapper->find( $image_id, true );
// ensure that we don't transpose the image from one gallery to another in case a remoteId is passed in for the image but the gallery associated to the collection cannot be found.
if ( $ngg_image && $ngg_image->galleryid != $gallery->id() ) {
$ngg_image = null;
$image_id = null;
}
$image_error = null;
if ( $image_action == 'delete' ) {
// image was deleted.
if ( $ngg_image != null ) {
$settings = \Imagely\NGG\Settings\Settings::get_instance();
$delete_fine = true;
if ( $settings->get( 'deleteImg' ) ) {
if ( ! $storage->delete_image( $ngg_image ) ) {
/* translators: %1$s: image filename */
$image_error = __( 'Could not delete image file(s) from disk (%1$s).', 'nggallery' );
}
} elseif ( ! $image_mapper->destroy( $ngg_image ) ) {
/* translators: %1$s: image filename */
$image_error = __( 'Could not remove image from gallery (%1$s).', 'nggallery' );
}
if ( $image_error == null ) {
do_action( 'ngg_delete_picture', $ngg_image->{$ngg_image->id_field}, $ngg_image );
$image_status = 'done';
}
} else {
/* translators: %1$s: image filename */
$image_error = __( 'Could not remove image because image was not found (%1$s).', 'nggallery' );
}
} else {
// image was added or edited and needs updating.
$image_data = null;
if ( $image_data_key != null ) {
if ( ! isset( $extra_data['__queuedImages'][ $image_data_key ] ) ) {
if ( isset( $extra_data[ $image_data_key ] ) ) {
$image_data_arr = $extra_data[ $image_data_key ];
// is_string(), not a (string) cast: a multi-file "file_data_x[]" upload
// makes tmp_name an array, and casting that emits an
// "Array to string conversion" warning before failing anyway.
$image_tmp_name = is_array( $image_data_arr ) && isset( $image_data_arr['tmp_name'] ) && is_string( $image_data_arr['tmp_name'] )
? $image_data_arr['tmp_name']
: '';
// Only bytes PHP itself received as an upload in *this* request may be
// read here. Every other value in $extra_data is caller-supplied JSON,
// and taking a path out of it turned the execute endpoint into an
// arbitrary file read.
//
// is_uploaded_file() is the whole check: it is PHP's own authoritative
// answer, so a path allowlist ANDed after it can only add false
// negatives - which is #460, a publish failing on every image. Only
// #460 items 1-4 are handled at HEAD (allowlist dropped, UPLOAD_ERR
// surfaced, relative tmp_name resolved, strict result compare). Item 5
// says to KEEP the prefix allowlist on the *other* call site - do not
// re-add it here, which is what produced the reported failure.
$upload_error = is_array( $image_data_arr ) && isset( $image_data_arr['error'] )
? (int) $image_data_arr['error']
: UPLOAD_ERR_OK;
if ( UPLOAD_ERR_OK !== $upload_error ) {
$image_error = str_replace(
'%2$s',
self::describe_upload_error( $upload_error ),
/* translators: %1$s: image filename, %2$s: PHP upload error constant name */
__( 'The web server rejected the upload of image (%1$s): %2$s.', 'nggallery' )
);
} elseif ( '' !== $image_tmp_name
&& in_array( (string) $image_data_key, $upload_keys, true )
&& is_uploaded_file( $image_tmp_name ) ) {
$image_data = file_get_contents( $this->resolve_upload_tmp_path( $image_tmp_name ) );
}
}
// Strict, and only when nothing more specific was recorded. The loose
// `$image_data == null` that used to stand here ran unconditionally, so it
// overwrote the upload-error message set just above with the generic
// string - which made that message dead code in every configuration it
// was written for. It also folded four distinct causes into one sentence:
// path rejected, read failed (false), zero-byte read (''), and upload
// failed. #460 item 4.
if ( null === $image_error && ! is_string( $image_data ) ) {
/* translators: %1$s: image filename */
$image_error = __( 'Could not obtain data for image (%1$s).', 'nggallery' );
} elseif ( null === $image_error && '' === $image_data ) {
$image_data = null;
/* translators: %1$s: image filename */
$image_error = __( 'The image file was read but is empty (%1$s).', 'nggallery' );
}
} else {
$image_status = 'queued';
}
} else {
$image_path = $images_folder . $image_path;
if ( $this->is_staged_image_path( $image_path, $images_folder, $wp_fs )
&& $wp_fs->exists( $image_path ) ) {
$image_data = $wp_fs->get_contents( $image_path );
// The staged path is assembled from the stored job's app_config and
// task_list, so containment alone cannot say the target is a photo
// rather than debug.log or a plugin's source. Requiring real image
// bytes is the bound the caller does not control, and a legitimate
// staged file is always an image, so this costs nothing.
//
// It closes the non-image case only. An enqueuer can still point
// $images_folder at, say, wp-content/uploads and have a media-library
// JPEG there republished and then deleted - that is #1003's remaining
// half, needs an authenticated capability-gated enqueue, and is not
// closed here. See the note on is_staged_image_path(); #1003 must stay
// open.
// Bytes AND the source extension. import_image_file() checks only the
// destination name, so a file with a valid image header and any other
// extension - "debug.php" carrying a PNG header - was read out,
// republished under the destination name, and then deleted, because the
// delete is skipped only for non-image bytes. Credit to #1032 for this
// one; reproduced before fixing.
$staged_not_image = is_string( $image_data ) && '' !== $image_data
&& ( ! $this->is_image_data( $image_data, (string) $image_filename )
|| ! $storage->is_allowed_image_extension( $image_path ) );
if ( $staged_not_image ) {
$image_data = null;
/* translators: %1$s: image filename */
$image_error = __( 'The staged file is not an image, so it was left in place (%1$s).', 'nggallery' );
}
// get_contents() returns false, not null, on a failed read, so the
// loose test that used to stand below set no error, skipped the
// upload, and still counted the image finished - it vanished while
// the task reported success.
if ( null === $image_error && ! is_string( $image_data ) ) {
/* translators: %1$s: image filename */
$image_error = __( 'The image file could not be read from the staging folder (%1$s).', 'nggallery' );
} elseif ( null === $image_error && '' === $image_data ) {
$image_data = null;
/* translators: %1$s: image filename */
$image_error = __( 'The image file was read but is empty (%1$s).', 'nggallery' );
}
// Not deleted when the file turned out not to be an image: that is
// someone else's file, and removing it would be the destructive half
// of #1003.
//
// Otherwise deleted on both outcomes, which keeps the invariant the rmdir()
// below documents. Deferring it to preserve a "retryable" copy was
// wrong: _array_filter_by_entry() makes a re-run skip this image, so
// the file was orphaned in a web-served directory that rmdir() could
// then not remove. Reporting the failure above is the actual fix.
// delete temporary image.
if ( ! $staged_not_image && ! $wp_fs->delete( $image_path ) ) {
// Checked for the same reason as the rmdir() below - a failed delete
// leaves the full-resolution original in a web-served directory with
// the task marked done - and logged here because the file remaining
// is also what makes that rmdir() fail, so its log names the symptom
// rather than the cause.
$this->log_once(
'staged_delete_failed_' . md5( (string) $image_path ),
'NextGEN Gallery: Lightroom could not remove a staged original, so it is still on disk: ' . wp_strip_all_tags( (string) $image_path, true )
);
}
} elseif ( ! $this->is_staged_image_path( $image_path, $images_folder, $wp_fs ) ) {
// Distinguished from a missing file, and recorded: a host whose staging
// folder falls outside the allow-list published fine before this release
// and now fails every image, so "could not find" sends the photographer
// looking for the wrong problem.
//
// The two refusals are also told apart. A plugin/theme/translation
// directory IS inside the allow-list, so telling that photographer the
// path is "outside the folders this site allows" is advice they cannot
// act on - and for the site owner, someone steering a read-and-delete at
// plugin files should not look like an open_basedir false positive.
if ( $this->is_code_directory_path( $image_path ) ) {
$this->log_once(
'staged_path_code_dir_' . md5( (string) $image_path ),
'NextGEN Gallery: Lightroom refused a staged path inside a plugin, theme or translation directory: ' . wp_strip_all_tags( (string) $image_path, true )
);
/* translators: %1$s: image filename */
$image_error = __( 'NextGEN does not read images from plugin, theme or translation folders (%1$s).', 'nggallery' );
} else {
$this->log_once(
'staged_path_refused_' . md5( (string) $image_path ),
'NextGEN Gallery: Lightroom refused a staged path outside the permitted locations: ' . wp_strip_all_tags( (string) $image_path, true )
);
/* translators: %1$s: image filename */
$image_error = __( 'The staged file is outside the folders this site allows NextGEN to read (%1$s).', 'nggallery' );
}
} elseif ( is_multisite() ) {
/* translators: %1$s: image filename */
$image_error = __( 'Could not find image file for image (%1$s). Using FTP Upload Method in Multisite is not recommended.', 'nggallery' );
} else {
/* translators: %1$s: image filename */
$image_error = __( 'Could not find image file for image (%1$s).', 'nggallery' );
}
}
if ( $image_data != null ) {
try {
$ngg_image = $storage->upload_base64_image( $gallery, $image_data, $image_filename, $image_id, true );
$image_mapper->reimport_metadata( $ngg_image );
if ( $ngg_image != null ) {
$image_status = 'done';
$image_id = is_int( $ngg_image ) ? $ngg_image : $ngg_image->{$ngg_image->id_field};
}
} catch ( \E_NoSpaceAvailableException $e ) {
/* translators: %1$s: image filename */
$image_error = __( 'No space available for image (%1$s).', 'nggallery' );
} catch ( \E_UploadException $e ) {
/* translators: %1$s: image filename */
$upload_error_message = str_replace( '%', '%%', $e->getMessage() );
$image_error = $upload_error_message . __( ' (%1$s).', 'nggallery' );
} catch ( \E_No_Image_Library_Exception $e ) {
/* translators: %1$s: image filename */
$image_error = __( 'No image library present, image uploads will fail (%1$s).', 'nggallery' );
// no point in continuing if the image library is not present but we don't break here to ensure that all images are processed (otherwise they'd be processed in further fruitless handle_job calls).
} catch ( \E_InsufficientWriteAccessException $e ) {
/* translators: %1$s: image filename */
$image_error = __( 'Inadequate system permissions to write image (%1$s).', 'nggallery' );
} catch ( \E_InvalidEntityException $e ) {
/* translators: 1: image filename, 2: image ID */
$image_error = __( 'Requested image with id (%2$s) doesn\'t exist (%1$s).', 'nggallery' );
} catch ( \E_EntityNotFoundException $e ) {
// Gallery doesn't exist - already checked above so this should never happen.
unset( $e );
}
}
} if ( $image_error != null ) {
$image_status = 'error';
$image['error'] = [
'level' => 'fatal',
'message' => sprintf( $image_error, $image_filename, $image_id ),
];
}
if ( $image_id ) {
$image['id'] = $image_id;
}
if ( $image_status ) {
$image['status'] = $image_status;
}
if ( $image_status != 'queued' ) {
// append processed image to result image_list array.
$result_images[] = $image;
}
if ( $this->should_stop_execution() ) {
break;
}
}
$task_result['image_list'] = $result_images;
$image_list_unfinished = count( $result_images ) < $image_count;
// if images have finished processing, remove the folder used to store the temporary images (the folder should be empty due to delete() calls above).
if ( ! $image_list_unfinished && $storage_path != null && $storage_path != $fs_sep && $path_prefix != null && $path_prefix != $fs_sep ) {
if ( $this->is_permitted_fs_path( $images_folder, $wp_fs ) ) {
// Not recursive, and returns false on a non-empty directory.
// Discarding that made the only non-removal that happens in
// practice the silent one.
if ( ! $wp_fs->rmdir( $images_folder ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not remove the staging directory, so it is still on disk: ' . wp_strip_all_tags( (string) $images_folder, true ) );
}
} else {
// Without this the refusal is invisible: the task still reports
// finished, and the staging directory just stays on disk, one per
// publish, with no symptom an admin can trace back to here.
// Logged ungated for the same reason as the quick-execute catch
// above - a production install does not define WP_DEBUG.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom left a staging directory in place because it is outside the permitted locations: ' . wp_strip_all_tags( (string) $images_folder, true ) );
}
}
} elseif ( $wp_fs == null ) {
/* translators: %1$s: gallery ID */
$error = __( 'Could not access file system for gallery (%1$s).', 'nggallery' );
}
// save() is now truthful about a no-op update - TableDriver::save_entity()
// tests `false !== $this->_update()` rather than truthiness - so a
// falsy return here means the write really was refused. The previous
// guard inferred that from $wpdb->last_error, which missed the
// wpdb::update() paths that return false before issuing any query
// and so reported a rejected save as success.
if ( ! $gallery->save() ) {
if ( $error == null ) {
$gal_errors = '[' . wp_json_encode( $gallery->validation() ) . ']';
/* translators: %1$s: gallery ID */
$error = __( 'Failed to save modified gallery (%1$s). ', 'nggallery' ) . $gal_errors;
}
}
}
} elseif ( $error == null ) {
/* translators: %1$s: gallery ID */
$error = __( 'Could not find gallery (%1$s).', 'nggallery' );
}
if ( isset( $task_result['image_list'] ) && $gallery != null ) {
$task_result['object_id'] = $gallery->id();
}
if ( $error == null ) {
$task_status = 'done';
$task_result['object_id'] = $gallery->id();
if ( $warning != null ) {
// Non-fatal: the task stays 'done' so the client does not fail the
// publish, but the refused property names still reach the user.
$task_error = [
'level' => 'warning',
'message' => sprintf( $warning, (string) $task_query['id'] ),
];
}
} else {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => sprintf( $error, (string) $task_query['id'] ),
];
}
if ( $image_list_unfinished ) {
// we override the status of the task when the image list has not finished processing.
$task_status = 'unfinished';
}
} else {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => __( 'No gallery was specified to edit.', 'nggallery' ),
];
}
break;
case 'album_add':
if ( ! $this->task_is_authorized( 'nextgen_edit_album' ) ) {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => __( 'Not authorized to create an album.', 'nggallery' ),
];
break;
}
$mapper = AlbumMapper::get_instance();
$name = isset( $task_object['name'] ) ? $task_object['name'] : '';
$desc = isset( $task_object['description'] ) ? $task_object['description'] : '';
$previewpic = isset( $task_object['preview_image'] ) ? $task_object['preview_image'] : 0;
$sortorder = isset( $task_object['sort_order'] ) ? $task_object['sort_order'] : '';
$page_id = isset( $task_object['page_id'] ) ? $task_object['page_id'] : 0;
$album = null;
if ( isset( $task_query['id'] ) ) {
$album = $mapper->find( $task_query['id'], true );
}
if ( $album == null ) {
$album = $mapper->create(
[
'name' => $name,
'previewpic' => $previewpic,
'albumdesc' => $desc,
'sortorder' => $sortorder,
'pageid' => $page_id,
]
);
if ( ! $album || ! $album->save() ) {
$album = null;
}
}
if ( $album != null ) {
$task_status = 'done';
$task_result['object_id'] = $album->id();
} else {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => __( 'Album creation failed.', 'nggallery' ),
];
}
break;
case 'album_remove':
case 'album_edit':
if ( isset( $task_query['id'] ) ) {
$mapper = AlbumMapper::get_instance();
$album = $mapper->find( $task_query['id'], true );
$error = null;
$warning = null;
// Albums carry no owner column, so there is no ownership dimension to
// narrow on - but the capability still has to hold at execution time,
// for the same reason it does for galleries.
if ( ! $this->task_is_authorized( 'nextgen_edit_album' ) ) {
/* translators: %1$s: album ID */
$error = __( 'Not authorized to edit album (%1$s).', 'nggallery' );
$album = null;
}
if ( $album ) {
if ( $task_type == 'album_remove' ) {
if ( ! $mapper->destroy( $album ) ) {
/* translators: %1$s: album ID */
$error = __( 'Failed to remove album (%1$s).', 'nggallery' );
}
} elseif ( $task_type == 'album_edit' ) {
if ( isset( $task_object['name'] ) ) {
$album->name = $task_object['name'];
}
if ( isset( $task_object['description'] ) ) {
$album->albumdesc = $task_object['description'];
}
if ( isset( $task_object['preview_image'] ) ) {
$album->previewpic = $task_object['preview_image'];
}
if ( isset( $task_object['property_list'] ) ) {
$properties = $task_object['property_list'];
// Only descriptive schema fields may be assigned - see the
// gallery_edit case above.
$assignable = MassAssignment::filter( $properties, 'album', $refused );
foreach ( $assignable as $key => $value ) {
$album->$key = $value;
}
if ( $refused ) {
// See the gallery_edit case above.
// Concatenated for the same reason as the gallery case above.
/* translators: %1$s: album ID. The refused property names are appended. */
$warning = __( 'Album (%1$s) was saved, but these properties may not be set from Lightroom and were ignored: ', 'nggallery' )
. MassAssignment::describe_refused( $refused );
}
}
if ( isset( $task_object['item_list'] ) ) {
$item_list = $task_object['item_list'];
$sortorder = $album->sortorder;
$count = count( $sortorder );
$album_items = [];
for ( $index = 0; $index < $count; $index++ ) {
$album_items[ $sortorder[ $index ] ] = $index;
}
foreach ( $item_list as $item_info ) {
$item_id = isset( $item_info['id'] ) ? $item_info['id'] : null;
$item_type = isset( $item_info['type'] ) ? $item_info['type'] : null;
$item_index = isset( $item_info['index'] ) ? $item_info['index'] : null;
// translate ID in case this gallery has been created as part of this job.
$item_id = $this->get_object_id( $item_id, $task_list_result );
if ( $item_id != null ) {
if ( $item_type == 'album' ) {
$item_id = 'a' . $item_id;
}
$album_items[ $item_id ] = $count + $item_index;
}
}
asort( $album_items );
$album->sortorder = array_keys( $album_items );
}
// Same as the gallery twin above: the no-op-update problem is fixed in
// TableDriver::save_entity(), so a falsy save() here is a real refusal
// and needs no $wpdb->last_error inference. Fixing it in the mapper is
// what closes both branches at once - the previous approach had to be
// applied per call site, which is how the album half stayed broken
// after the gallery half was fixed in April.
if ( ! $mapper->save( $album ) ) {
if ( $error == null ) {
$alb_errors = '[' . wp_json_encode( $album->validation() ) . ']';
/* translators: %1$s: album ID */
$error = __( 'Failed to save modified album (%1$s). ', 'nggallery' ) . $alb_errors;
}
}
}
} elseif ( $error == null ) {
/* translators: %1$s: album ID */
$error = __( 'Could not find album (%1$s).', 'nggallery' );
}
if ( $error == null ) {
$task_status = 'done';
$task_result['object_id'] = $album->id();
if ( $warning != null ) {
// See the gallery_edit case above.
$task_error = [
'level' => 'warning',
'message' => sprintf( $warning, (string) $task_query['id'] ),
];
}
} else {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => sprintf( $error, (string) $task_query['id'] ),
];
}
} else {
$task_status = 'error';
$task_error = [
'level' => 'fatal',
'message' => __( 'No album was specified to edit.', 'nggallery' ),
];
}
break;
case 'gallery_list_get':
$mapper = GalleryMapper::get_instance();
$gallery_list = $mapper->find_all();
$result_list = [];
foreach ( $gallery_list as $gallery ) {
$gallery_result = [
'id' => $gallery->id(),
'name' => $gallery->name,
'title' => $gallery->title,
'description' => $gallery->galdesc,
'preview_image' => $gallery->previewpic,
];
$result_list[] = $gallery_result;
}
$task_status = 'done';
$task_result['gallery_list'] = $result_list;
break;
case 'image_list_move':
break;
}
$task_item['result'] = $task_result;
$task_item['status'] = $task_status;
$task_item['error'] = $task_error;
// for previously finished tasks, store the result as it may be needed by future tasks.
if ( $task_id != null && $task_result != null ) {
$task_list_result[ $task_id ] = $task_result;
}
// if the task has finished, either successfully or unsuccessfully, increase count for done tasks.
if ( 'unfinished' != $task_status ) {
++$done_count;
}
if ( $this->should_stop_execution() ) {
break;
}
}
if ( ! $this->set_job_task_list( $job_id, $task_list ) ) {
// Hand the callers this list rather than letting them re-read a stale one -
// get_job_task_list() prefers it - so the statuses reach the client at least
// once. Not returned as unfinished: a retry would re-read the stale list with
// every image 'pending' and re-upload the delivered ones as duplicates.
$this->unpersisted_task_lists[ $job_id ] = $task_list;
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not persist task results for job ' . wp_strip_all_tags( (string) $job_id, true ) . '; serving the in-memory list to the caller.' );
} else {
// Cleared on success, so a copy kept by an earlier failure cannot shadow what was
// just written - get_job_task_list() prefers the in-memory entry.
unset( $this->unpersisted_task_lists[ $job_id ] );
}
if ( $task_count > $done_count + $skip_count ) {
// unfinished tasks, return false.
return false;
} else {
$upload_method = isset( $app_config['upload_method'] ) ? $app_config['upload_method'] : 'ftp';
if ( 'ftp' == $upload_method ) {
// everything was finished, write status file.
$status_file = '_ngg_job_status_' . strval( $job_id ) . '.txt';
$status_content = wp_json_encode( $task_list );
// Contained like every other filesystem operation here: $path_prefix and
// $full_path are request JSON, so "full_path": "/../.." wrote attacker-shaped
// JSON outside wp-content, and the fallback branch used no root prefix at all.
$status_written = false;
if ( null != $wp_fs ) {
$status_path = $path_prefix . $fs_sep . $status_file;
$status_path = str_replace( [ '\\', '/' ], $fs_sep, $status_path );
if ( $this->is_permitted_fs_path( $status_path, $wp_fs ) ) {
$status_written = (bool) $wp_fs->put_contents( $status_path, $status_content );
} else {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom did not write a job status file outside the permitted locations: ' . wp_strip_all_tags( (string) $status_path, true ) );
}
} else {
// Currently unreachable: a failed WP_Filesystem() above assigns
// WP_Filesystem_Direct, so $wp_fs is never null here. Kept, and guarded on
// the assumption that if it ever runs the write is local.
$status_path = str_replace( $ftp_path, $root_path, $full_path ) . DIRECTORY_SEPARATOR . $status_file;
$status_path = str_replace( [ '\\', '/' ], DIRECTORY_SEPARATOR, $status_path );
if ( $this->is_valid_filename( $status_path ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
$status_written = false !== file_put_contents( $status_path, $status_content );
} else {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom did not write a job status file outside the permitted locations: ' . wp_strip_all_tags( (string) $status_path, true ) );
}
}
if ( ! $status_written ) {
// The status file is how the client learns an FTP publish finished, and a
// bare true here removes the job - leaving the client polling for a file
// that will never appear. Recorded on the task instead, which both legs
// read back before remove_job().
$task_list = $this->record_job_warning(
$task_list,
__( 'The publish finished, but its status file could not be written to the upload folder. Lightroom may keep waiting for it.', 'nggallery' )
);
// Refreshed either way: the caller reads this list back, and an entry left by
// the earlier persist failure would otherwise shadow the warning even when
// this write succeeded.
$this->unpersisted_task_lists[ $job_id ] = $task_list;
if ( ! $this->set_job_task_list( $job_id, $task_list ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'NextGEN Gallery: Lightroom could not record that the job status file was not written, for job ' . wp_strip_all_tags( (string) $job_id, true ) . '.' );
}
}
}
return true;
}
}
}