Controller.php000064400000007221147207141260007406 0ustar00exitForbidden(); } /** * Emulate permission denied screen as performed in wp-admin/admin.php */ protected function exitForbidden(){ do_action( 'admin_page_access_denied' ); wp_die( __( 'You do not have sufficient permissions to access this page.','default' ), 403 ); } // @codeCoverageIgnore /** * Set a nonce for the current page for when it submits a form * @return Loco_mvc_ViewParams */ public function setNonce( $action ){ $name = 'loco-nonce'; $value = wp_create_nonce( $action ); $nonce = new Loco_mvc_ViewParams( compact('name','value','action') ); $this->set('nonce', $nonce ); return $nonce; } /** * Check if a valid nonce has been sent in current request. * Fails if nonce is invalid, but returns false if not sent so scripts can exit accordingly. * @throws Loco_error_Exception * @param string $action action for passing to wp_verify_nonce * @return bool true if data has been posted and nonce is valid */ public function checkNonce( $action ){ $posted = false; $name = 'loco-nonce'; if( isset($_REQUEST[$name]) ){ $value = $_REQUEST[$name]; if( wp_verify_nonce( $value, $action ) ){ $posted = true; } else { throw new Loco_error_Exception('Failed security check for '.$name); } } return $posted; } /** * Filter callback for `translations_api' * Ensures silent failure of translations_api when network disabled, see $this->getAvailableCore */ public function filter_translations_api( $value = false ){ if( apply_filters('loco_allow_remote', true ) ){ return $value; } // returning error here has the safe effect as returning empty translations list return new WP_Error( -1, 'Translations API blocked by loco_allow_remote filter' ); } /** * Filter callback for `pre_http_request` * Ensures fatal error if we failed to handle offline mode earlier. */ public function filter_pre_http_request( $value = false ){ if( apply_filters('loco_allow_remote', true ) ){ return $value; } // little point returning WP_Error error because WordPress will just show "unexpected error" throw new Loco_error_Exception('HTTP request blocked by loco_allow_remote filter' ); } /** * number_format_i18n filter callback because our admin screens assume number_format_i18n() returns unescaped text, not HTML. * @param string * @return string */ public function filter_number_format_i18n( $formatted = '' ){ return html_entity_decode($formatted,ENT_NOQUOTES,'UTF-8'); } } FileParams.php000064400000012704147207141260007310 0ustar00= 1024 ){ $i++; $dp++; $n /= 1024; } $s = number_format( $n, $dp, '.', ',' ); // trim trailing zeros from decimal places $a = explode('.',$s); if( isset($a[1]) ){ $s = $a[0]; $d = trim($a[1],'0') and $s .= '.'.$d; } $units = [ ' bytes', ' KB', ' MB', ' GB', ' TB' ]; $s .= $units[$i]; return $s; } /** * @return Loco_mvc_FileParams */ public static function create( Loco_fs_File $file ) { return new Loco_mvc_FileParams( [], $file ); } /** * Override does lazy property initialization * @param array $props initial extra properties */ public function __construct( array $props, Loco_fs_File $file ){ parent::__construct( [ 'name' => '', 'path' => '', 'relpath' => '', 'reltime' => '', 'bytes' => 0, 'size' => '', 'imode' => '', 'smode' => '', 'owner' => '', 'group' => '', ] + $props ); $this->file = $file; } /** * {@inheritdoc} * Override to get live information from file object */ #[ReturnTypeWillChange] public function offsetGet( $prop ){ $getter = [ $this, '_get_'.$prop ]; if( is_callable($getter) ){ return call_user_func( $getter ); } return parent::offsetGet($prop); } /** * {@inheritdoc} * Override to ensure all properties populated */ #[ReturnTypeWillChange] public function getArrayCopy(){ $a = []; foreach( $this as $prop => $dflt ){ $a[$prop] = $this[$prop]; } return $a; } /** * @internal * @return string */ private function _get_name(){ return $this->file->basename(); } /** * @internal * @return string */ private function _get_path(){ return $this->file->getPath(); } /** * @internal * @return string */ private function _get_relpath(){ return $this->file->getRelativePath( loco_constant('WP_CONTENT_DIR') ); } /** * Using slightly modified version of WordPress's Human time differencing * + Added "Just now" when in the last 30 seconds * @internal * @return string */ private function _get_reltime(){ $time = $this->has('mtime') ? $this['mtime'] : $this->file->modified(); $time_diff = time() - $time; // use same time format as posts listing when in future or more than a day ago if( $time_diff < 0 || $time_diff >= 86400 ){ return Loco_mvc_ViewParams::date_i18n( $time, __('Y/m/d','default') ); } if( $time_diff < 30 ){ // translators: relative time when something happened in the last 30 seconds return __('Just now','loco-translate'); } // translators: %s: Human-readable time difference. return sprintf( __('%s ago','default'), human_time_diff($time) ); } /** * @internal * @return int */ private function _get_bytes(){ return $this->file->size(); } /** * @internal * @return string */ private function _get_size(){ return self::renderBytes( $this->_get_bytes() ); } /** * Get octal file mode * @internal * @return string */ private function _get_imode(){ $mode = new Loco_fs_FileMode( $this->file->mode() ); return (string) $mode; } /** * Get rwx file mode * @internal * @return string */ private function _get_smode(){ $mode = new Loco_fs_FileMode( $this->file->mode() ); return $mode->format(); } /** * Get file owner name * @internal * @return string */ private function _get_owner(){ if( ( $uid = $this->file->uid() ) && function_exists('posix_getpwuid') && ( $a = posix_getpwuid($uid) ) ){ return $a['name']; } return sprintf('%u',$uid); } /** * Get group owner name * @internal * @return string */ private function _get_group(){ if( ( $gid = $this->file->gid() ) && function_exists('posix_getpwuid') && ( $a = posix_getgrgid($gid) ) ){ return $a['name']; } return sprintf('%u',$gid); } /** * Print pseudo console line * @return string; */ public function ls(){ $this->e('smode'); echo ' '; $this->e('owner'); echo ':'; $this->e('group'); echo ' '; $this->e('relpath'); return ''; } }PostParams.php000064400000005154147207141260007357 0ustar00getArrayCopy(), false, '&' ); foreach( explode('&',$query) as $str ){ $serial[] = array_map( 'urldecode', explode( '=', $str, 2 ) ); } return $serial; } }ViewParams.php000064400000011771147207141260007346 0ustar00setTimestamp($u); return date_i18n( $f, $u + $d->getOffset() ); } /** * Wrapper for sprintf so we can handle PHP 8 exceptions * @param string $format * @return string */ public static function format( $format, array $args ){ try { return vsprintf($format,$args); } // Note that PHP8 will throw Error (not Exception), PHP 7 will trigger E_WARNING catch( Error $e ){ Loco_error_AdminNotices::warn( $e->getMessage().' in vsprintf('.var_export($format,true).')' ); return ''; } } /** * @internal * @param string $p property name * @return mixed */ public function __get( $p ){ return $this->offsetExists($p) ? $this->offsetGet($p) : null; } /** * Test if a property exists, even if null * @param string $p property name * @return bool */ public function has( $p ){ return $this->offsetExists($p); } /** * Print escaped property value * @param string $p property key * @return string empty string */ public function e( $p ){ $text = $this->__get($p); echo $this->escape( $text ); return ''; } /** * Print property as string date, including time * @param string $p property name * @param string $f date format * @return string empty string */ public function date( $p, $f = null ){ $u = (int) $this->__get($p); if( $u > 0 ){ echo $this->escape( self::date_i18n($u,$f) ); } return ''; } /** * Print property as a string-formatted number * @param string $p property name * @param int $dp optional decimal places * @return string empty string */ public function n( $p, $dp = 0 ){ // number_format_i18n is pre-escaped for HTML echo number_format_i18n( $this->__get($p), $dp ); return ''; } /** * Print property with passed formatting string * e.g. $params->f('name', 'My name is %s' ); * @param string $p property name * @param string $f formatting string * @return string empty string */ public function f( $p, $f = '%s' ){ echo $this->escape( self::format( $f, [$this->__get($p)] ) ); return ''; } /** * Print property value for JavaScript * @param string $p property name * @return string empty string */ public function j( $p ){ echo json_encode($this->__get($p) ); return ''; } /** * @return array */ #[ReturnTypeWillChange] public function jsonSerialize(){ return $this->getArrayCopy(); } /** * Fetch whole object as JSON * @return string */ public function exportJson(){ return json_encode( $this->jsonSerialize() ); } /** * Merge parameters into ours * @return Loco_mvc_ViewParams */ public function concat( ArrayObject $more ){ foreach( $more as $name => $value ){ $this[$name] = $value; } return $this; } /** * Debugging function * @codeCoverageIgnore */ public function dump(){ echo '
',$this->escape( json_encode( $this->getArrayCopy(),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE ) ),'
'; } /** * @param callable $callback * @return Loco_mvc_ViewParams */ public function sort( $callback ){ $raw = $this->getArrayCopy(); uasort( $raw, $callback ); $this->exchangeArray( $raw ); return $this; } }AjaxController.php000064400000004125147207141260010212 0ustar00auth(); $this->output = new ArrayObject; $this->input = new ArrayObject( $args ); // avoid fatal error if json extension is missing loco_check_extension('json'); } /** * Get posted data and validate nonce in the process * @return Loco_mvc_PostParams */ protected function validate(){ $route = $this->input['route']; if( ! $this->checkNonce($route) ){ throw new Loco_error_Exception( sprintf('Ajax %s action requires postdata with nonce',$route) ); } return Loco_mvc_PostParams::get(); } /** * {@inheritdoc} */ public function get( $prop ){ return isset($this->input[$prop]) ? $this->input[$prop] : null; } /** * {@inheritdoc} */ public function set( $prop, $value ){ $this->output[$prop] = $value; return $this; } /** * @return string JSON */ public function render(){ $data = [ 'data' => $this->output->getArrayCopy(), ]; // non-fatal notices deliberately not in "error" key $array = Loco_error_AdminNotices::destroy(); if( $array ){ $data['notices'] = $array; } return json_encode( $data ); } /** * Pretty json encode if PHP version allows * protected function json_encode( $data ){ $opts = 0; if( defined('JSON_PRETTY_PRINT') ){ $opts |= JSON_PRETTY_PRINT; } if( defined('JSON_UNESCAPED_SLASHES') ){ $opts |= JSON_UNESCAPED_SLASHES; } return json_encode( $data, $opts ); }*/ }AjaxRouter.php000064400000015465147207141260007360 0ustar00 $route, 'action' => 'loco_ajax', 'loco-nonce' => wp_create_nonce($route), ]; return admin_url('admin-ajax.php','relative').'?'.http_build_query($args); } /** * Create a new ajax router and starts buffering output immediately */ public function __construct(){ $this->buffer = Loco_output_Buffer::start(); parent::__construct(); } /** * "init" action callback. * early-ish hook that ensures controllers can initialize */ public function on_init(){ try { $class = self::routeToClass( $_REQUEST['route'] ); // autoloader will throw error if controller class doesn't exist $this->ctrl = new $class; $this->ctrl->_init( $_REQUEST ); // hook name compatible with AdminRouter, plus additional action for ajax hooks to set up do_action('loco_admin_init', $this->ctrl ); do_action('loco_ajax_init', $this->ctrl ); } catch( Loco_error_Exception $e ){ $this->ctrl = null; // throw $e; // <- debug } } /** * @param string $route * @return string */ private static function routeToClass( $route ){ $route = explode( '-', $route ); // convert route to class name, e.g. "foo-bar" => "Loco_ajax_foo_BarController" $key = count($route) - 1; $route[$key] = ucfirst( $route[$key] ); return 'Loco_ajax_'.implode('_',$route).'Controller'; } /** * Common ajax hook for all Loco admin JSON requests * Note that tests call renderAjax directly. * @codeCoverageIgnore */ public function on_wp_ajax_loco_json(){ $json = $this->renderAjax(); $this->exitScript( $json, [ 'Content-Type' => 'application/json; charset=UTF-8', ] ); } /** * Additional ajax hook for download actions that won't be JSON * Note that tests call renderDownload directly. * @codeCoverageIgnore */ public function on_wp_ajax_loco_download(){ $file = null; $ext = null; $data = $this->renderDownload(); if( is_string($data) ){ $path = ( $this->ctrl ? $this->ctrl->get('path') : '' ) or $path = 'error.json'; $file = new Loco_fs_File( $path ); $ext = $file->extension(); } else if( $data instanceof Exception ){ $data = sprintf('%s in %s:%u', $data->getMessage(), basename($data->getFile()), $data->getLine() ); } else { $data = (string) $data; } $mimes = [ 'po' => 'application/x-gettext', 'pot' => 'application/x-gettext', 'mo' => 'application/x-gettext-translation', 'php' => 'application/x-httpd-php-source', 'json' => 'application/json', 'zip' => 'application/zip', 'xml' => 'text/xml', ]; $headers = []; if( $file instanceof Loco_fs_File && isset($mimes[$ext]) ){ $headers['Content-Type'] = $mimes[$ext].'; charset=UTF-8'; $headers['Content-Disposition'] = 'attachment; filename='.$file->basename(); } else { $headers['Content-Type'] = 'text/plain; charset=UTF-8'; } $this->exitScript( $data, $headers ); } /** * Exit script before WordPress shutdown, avoids hijacking of exit via wp_die_ajax_handler. * Also gives us a final chance to check for output buffering problems. * @codeCoverageIgnore */ private function exitScript( $str, array $headers ){ try { do_action('loco_admin_shutdown'); Loco_output_Buffer::clear(); $this->buffer = null; Loco_output_Buffer::check(); $headers['Content-Length'] = strlen($str); foreach( $headers as $name => $value ){ header( $name.': '.$value ); } } catch( Exception $e ){ Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) ); $str = $e->getMessage(); } echo $str; exit(0); } /** * Execute Ajax controller to render JSON response body * @return string */ public function renderAjax(){ try { // respond with deferred failure from initAjax if( ! $this->ctrl ){ $route = isset($_REQUEST['route']) ? $_REQUEST['route'] : ''; // translators: Fatal error where %s represents an unexpected value throw new Loco_error_Exception( sprintf( __('Ajax route not found: "%s"','loco-translate'), $route ) ); } // else execute controller to get json output $json = $this->ctrl->render(); if( is_null($json) || '' === $json ){ throw new Loco_error_Exception( __('Ajax controller returned empty JSON','loco-translate') ); } } catch( Loco_error_Exception $e ){ $json = json_encode( [ 'error' => $e->jsonSerialize(), 'notices' => Loco_error_AdminNotices::destroy() ] ); } catch( Exception $e ){ $e = Loco_error_Exception::convert($e); $json = json_encode( [ 'error' => $e->jsonSerialize(), 'notices' => Loco_error_AdminNotices::destroy() ] ); } $this->buffer->discard(); return $json; } /** * Execute ajax controller to render something other than JSON * @return string|Exception */ public function renderDownload(){ try { // respond with deferred failure from initAjax if( ! $this->ctrl ){ throw new Loco_error_Exception( __('Download action not found','loco-translate') ); } // else execute controller to get raw output $data = $this->ctrl->render(); if( is_null($data) || '' === $data ){ throw new Loco_error_Exception( __('Download controller returned empty output','loco-translate') ); } } catch( Exception $e ){ $data = $e; } $this->buffer->discard(); return $data; } }AdminController.php000064400000024377147207141260010372 0ustar00bench = microtime( true ); } $this->view = new Loco_mvc_View( $args ); $this->auth(); // check essential extensions on all pages so admin notices are shown loco_check_extension('json'); loco_check_extension('mbstring'); // add contextual help tabs to current screen if there are any if( $screen = get_current_screen() ){ try { $this->view->cd('/admin/help'); $tabs = $this->getHelpTabs(); // always append common help tabs $tabs[ __('Help & support','loco-translate') ] = $this->view->render('tab-support'); // set all tabs and common sidebar $i = 0; foreach( $tabs as $title => $content ){ $id = sprintf('loco-help-%u', $i++ ); $screen->add_help_tab( compact('id','title','content') ); } $screen->set_help_sidebar( $this->view->render('side-bar') ); $this->view->cd('/'); } // avoid critical errors rendering non-critical part of page catch( Loco_error_Exception $e ){ $this->view->cd('/'); Loco_error_AdminNotices::add( $e ); } } // helper properties for loading static resources $this->baseurl = plugins_url( '', loco_plugin_self() ); // add common admin page resources $this->enqueueStyle('admin', ['wp-jquery-ui-dialog'] ); // load colour scheme is user has non-default $skin = get_user_option('admin_color'); if( $skin && 'fresh' !== $skin ){ $this->enqueueStyle( 'skins/'.$skin ); } // core minimized admin.js loaded on all pages before any other Loco scripts $this->enqueueScript('admin', ['jquery-ui-dialog'] ); $this->init(); return $this; } /** * Post-construct initializer that may be overridden by child classes * @return void */ public function init(){ } /** * "admin_title" filter, modifies HTML document title if we've set one */ public function filter_admin_title( $admin_title, $title ){ if( $view_title = $this->get('title') ){ $admin_title = $view_title.' ‹ '.$admin_title; } return $admin_title; } /** * "admin_footer_text" filter, modifies admin footer only on Loco pages */ public function filter_admin_footer_text(){ $url = apply_filters('loco_external', 'https://localise.biz/'); return ''.sprintf( '%s Loco', esc_html(__('Loco Translate is powered by','loco-translate')), esc_url($url) ).''; } /** * "update_footer" filter, prints Loco version number in admin footer */ public function filter_update_footer( /*$text*/ ){ $html = sprintf( 'v%s', loco_plugin_version() ); if( $this->bench && ( $info = $this->get('_debug') ) ){ $html .= sprintf('%ss', number_format_i18n($info['time'],2) ); } return $html; } /** * "loco_external" filter callback, adds campaign identifier onto external links */ public function filter_loco_external( $url ){ $u = parse_url( $url ); if( isset($u['host']) && 'localise.biz' === $u['host'] ){ $query = http_build_query( [ 'utm_medium' => 'plugin', 'utm_campaign' => 'wp', 'utm_source' => 'admin', 'utm_content' => $this->get('_route') ] ); $url = 'https://localise.biz'.$u['path']; if( isset($u['query']) ){ $url .= '?'. $u['query'].'&'.$query; } else { $url .= '?'.$query; } if( isset($u['fragment']) ){ $url .= '#'.$u['fragment']; } } return $url; } /** * All admin screens must define help tabs, even if they return empty * @return array */ public function getHelpTabs(){ return []; } /** * {@inheritdoc} */ public function get( $prop ){ return $this->view->__get($prop); } /** * {@inheritdoc} */ public function set( $prop, $value ){ $this->view->set( $prop, $value ); return $this; } /** * Render template for echoing into admin screen * @param string $tpl template name * @param array $args template arguments * @return string */ public function view( $tpl, array $args = [] ){ /*if( ! $this->baseurl ){ throw new Loco_error_Debug('Did you mean to call $this->viewSnippet('.json_encode($tpl,JSON_UNESCAPED_SLASHES).') in '.get_class($this).'?'); }*/ $view = $this->view; foreach( $args as $prop => $value ){ $view->set( $prop, $value ); } // ensure JavaScript config present if any scripts are loaded if( $view->has('js') ) { $jsConf = $view->get( 'js' ); } else if( $this->scripts ){ $jsConf = new Loco_mvc_ViewParams; $this->set('js',$jsConf); } else { $jsConf = null; } if( $jsConf instanceof Loco_mvc_ViewParams ){ // ensure config has access to latest version information // we will use this to ensure scripts are not cached by browser, or hijacked by other plugins $jsConf->offsetSet('$v', [ loco_plugin_version(), $GLOBALS['wp_version']] ); $jsConf->offsetSet('$js', array_keys($this->scripts) ); $jsConf->offsetSet('WP_DEBUG', loco_debugging() ); // localize script if translations in memory if( is_textdomain_loaded('loco-translate') ){ $strings = new Loco_js_Strings; $jsConf->offsetSet('wpl10n',$strings->compile()); $strings->unhook(); unset( $strings ); // add currently loaded locale for passing plural equation into js. // note that plural rules come from our data, because MO is not trusted. $tag = apply_filters( 'plugin_locale', get_locale(), 'loco-translate' ); $jsConf->offsetSet('wplang', Loco_Locale::parse($tag) ); } // localized formatting from core translations global $wp_locale; if( is_object($wp_locale) && property_exists($wp_locale,'number_format') ){ $jsConf->offsetSet('wpnum', array_map([$this,'filter_number_format_i18n'],$wp_locale->number_format) ); } } // take benchmark for debugger to be rendered in footer if( $this->bench ){ $this->set('_debug', new Loco_mvc_ViewParams( [ 'time' => microtime(true) - $this->bench, ] ) ); } return $view->render( $tpl ); } /** * Shortcut to render template without full page arguments as per view * @param string $tpl * @return string */ public function viewSnippet( $tpl ){ return $this->view->render( $tpl ); } /** * Add CSS to head * @param string $name stem name of file, e.g "editor" * @param string[] $deps dependencies of this stylesheet * @return self */ public function enqueueStyle( $name, array $deps = [] ){ $base = $this->baseurl; if( ! $base ){ throw new Loco_error_Exception('Too early to enqueueStyle('.var_export($name,1).')'); } $id = 'loco-translate-'.strtr($name,'/','-'); // css always minified. sass in build env only $href = $base.'/pub/css/'.$name.'.css'; $vers = apply_filters( 'loco_static_version', loco_plugin_version(), $href ); wp_enqueue_style( $id, $href, $deps, $vers, 'all' ); return $this; } /** * Add JavaScript to footer * @param string $name stem name of file, e.g "editor" * @param string[] $deps dependencies of this script * @return string */ public function enqueueScript( $name, array $deps = [] ){ $base = $this->baseurl; if( ! $base ){ throw new Loco_error_Exception('Too early to enqueueScript('.json_encode($name).')'); } // use minimized javascript file. hook into script_loader_src to point at development source $href = $base.'/pub/js/min/'.$name.'.js'; $vers = apply_filters( 'loco_static_version', loco_plugin_version(), $href ); $id = 'loco-translate-'.strtr($name,'/','-'); wp_enqueue_script( $id, $href, $deps, $vers, true ); $this->scripts[$id] = $href; return $id; } /** * @param string $name * @return void */ public function dequeueScript( $name ){ $id = 'loco-translate-'.strtr($name,'/','-'); if( array_key_exists($id,$this->scripts) ){ wp_dequeue_script($id); unset($this->scripts[$id]); } } /** * @internal * @param string $tag * @param string $id * @return string */ public function filter_script_loader_tag( $tag, $id ) { if( array_key_exists($id,$this->scripts) ) { // Add element id for in-dom verification of expected scripts if( '