From 6f6d1f2776788be00c845c8cad5e200303348cf5 Mon Sep 17 00:00:00 2001 From: Yuri Astrakhan Date: Mon, 21 May 2007 04:34:48 +0000 Subject: [PATCH] API: recentchanges and usercontribs cleaned up to allow more precise properties selection. Minor security updates. --- includes/api/ApiFeedWatchlist.php | 6 +- includes/api/ApiQueryBacklinks.php | 6 +- includes/api/ApiQueryBase.php | 153 +-------------------- includes/api/ApiQueryCategories.php | 2 +- includes/api/ApiQueryImages.php | 2 +- includes/api/ApiQueryLinks.php | 2 +- includes/api/ApiQueryLogEvents.php | 2 +- includes/api/ApiQueryRecentChanges.php | 112 ++++++++++++--- includes/api/ApiQueryRevisions.php | 2 +- includes/api/ApiQueryUserContributions.php | 55 ++++---- includes/api/ApiQueryWatchlist.php | 27 ++-- 11 files changed, 156 insertions(+), 213 deletions(-) diff --git a/includes/api/ApiFeedWatchlist.php b/includes/api/ApiFeedWatchlist.php index 6b435e1bea..25d7118946 100644 --- a/includes/api/ApiFeedWatchlist.php +++ b/includes/api/ApiFeedWatchlist.php @@ -48,6 +48,10 @@ class ApiFeedWatchlist extends ApiBase { return new ApiFormatFeedWrapper($this->getMain()); } + /** + * Make a nested call to the API to request watchlist items in the last $hours. + * Wrap the result as an RSS/Atom feed. + */ public function execute() { global $wgFeedClasses, $wgSitename, $wgContLanguageCode; @@ -64,7 +68,7 @@ class ApiFeedWatchlist extends ApiBase { 'meta' => 'siteinfo', 'siprop' => 'general', 'list' => 'watchlist', - 'wlprop' => 'user|comment|timestamp', + 'wlprop' => 'title|user|comment|timestamp', 'wldir' => 'older', // reverse order - from newest to oldest 'wlend' => $endTime, // stop at this time 'wllimit' => 50 diff --git a/includes/api/ApiQueryBacklinks.php b/includes/api/ApiQueryBacklinks.php index 4aec802e8f..d10cec0df5 100644 --- a/includes/api/ApiQueryBacklinks.php +++ b/includes/api/ApiQueryBacklinks.php @@ -155,10 +155,8 @@ class ApiQueryBacklinks extends ApiQueryGeneratorBase { if (++ $count > $limit) { // We've reached the one extra which shows that there are additional pages to be had. Stop here... if ($redirect) { - $ns = $row-> { - $this->bl_ns }; - $t = $row-> { - $this->bl_title }; + $ns = $row-> { $this->bl_ns }; + $t = $row-> { $this->bl_title }; $continue = $this->getContinueRedirStr(false, 0, $ns, $t, $row->page_id); } else $continue = $this->getContinueStr($row->page_id); diff --git a/includes/api/ApiQueryBase.php b/includes/api/ApiQueryBase.php index 06f6612473..75185df09e 100644 --- a/includes/api/ApiQueryBase.php +++ b/includes/api/ApiQueryBase.php @@ -128,160 +128,15 @@ abstract class ApiQueryBase extends ApiBase { return $res; } - protected function addRowInfo($prefix, $row) { - - $vals = array (); - - // ID - if ( isset( $row-> { $prefix . '_id' } ) ) - $vals[$prefix . 'id'] = intval( $row-> { $prefix . '_id' } ); - - // Title - $title = ApiQueryBase :: addRowInfo_title($row, $prefix . '_namespace', $prefix . '_title'); - if ($title) - ApiQueryBase :: addTitleInfo($vals, $title); - - switch ($prefix) { - - case 'page' : - // page_is_redirect - @ $tmp = $row->page_is_redirect; - if ($tmp) - $vals['redirect'] = ''; - - break; - - case 'rc' : - // PageId - @ $tmp = $row->rc_cur_id; - if (!is_null($tmp)) - $vals['pageid'] = intval($tmp); - - @ $tmp = $row->rc_this_oldid; - if (!is_null($tmp)) - $vals['revid'] = intval($tmp); - - if ( isset( $row->rc_last_oldid ) ) - $vals['old_revid'] = intval( $row->rc_last_oldid ); - - $title = ApiQueryBase :: addRowInfo_title($row, 'rc_moved_to_ns', 'rc_moved_to_title'); - if ($title) { - if (!$title->userCanRead()) - return false; - ApiQueryBase :: addTitleInfo($vals, $title, 'new_'); - } - - if ( isset( $row->rc_patrolled ) ) - $vals['patrolled'] = ''; - - break; - - case 'log' : - // PageId - @ $tmp = $row->page_id; - if (!is_null($tmp)) - $vals['pageid'] = intval($tmp); - - if ($row->log_params !== '') { - $params = explode("\n", $row->log_params); - if ($row->log_type == 'move' && isset ($params[0])) { - $newTitle = Title :: newFromText($params[0]); - if ($newTitle) { - ApiQueryBase :: addTitleInfo($vals, $newTitle, 'new_'); - $params = null; - } - } - - if (!empty ($params)) { - $this->getResult()->setIndexedTagName($params, 'param'); - $vals = array_merge($vals, $params); - } - } - - break; - - case 'rev' : - // PageID - @ $tmp = $row->rev_page; - if (!is_null($tmp)) - $vals['pageid'] = intval($tmp); + protected static function addTitleInfo(&$arr, $title, $includeRestricted=false, $prefix='') { + if ($includeRestricted || $title->userCanRead()) { + $arr[$prefix . 'ns'] = intval($title->getNamespace()); + $arr[$prefix . 'title'] = $title->getPrefixedText(); } - - // Type - @ $tmp = $row-> { $prefix . '_type' }; - if (!is_null($tmp)) - $vals['type'] = $tmp; - - // Action - @ $tmp = $row-> { $prefix . '_action' }; - if (!is_null($tmp)) - $vals['action'] = $tmp; - - // Old ID - @ $tmp = $row-> { $prefix . '_text_id' }; - if (!is_null($tmp)) - $vals['oldid'] = intval($tmp); - - // User Name / Anon IP - @ $tmp = $row-> { $prefix . '_user_text' }; - if (is_null($tmp)) - @ $tmp = $row->user_name; - if (!is_null($tmp)) { - $vals['user'] = $tmp; - @ $tmp = !$row-> { $prefix . '_user' }; - if (!is_null($tmp) && $tmp) - $vals['anon'] = ''; - } - - // Bot Edit - @ $tmp = $row-> { $prefix . '_bot' }; - if (!is_null($tmp) && $tmp) - $vals['bot'] = ''; - - // New Edit - @ $tmp = $row-> { $prefix . '_new' }; - if (is_null($tmp)) - @ $tmp = $row-> { $prefix . '_is_new' }; - if (!is_null($tmp) && $tmp) - $vals['new'] = ''; - - // Minor Edit - @ $tmp = $row-> { $prefix . '_minor_edit' }; - if (is_null($tmp)) - @ $tmp = $row-> { $prefix . '_minor' }; - if (!is_null($tmp) && $tmp) - $vals['minor'] = ''; - - // Timestamp - @ $tmp = $row-> { $prefix . '_timestamp' }; - if (!is_null($tmp)) - $vals['timestamp'] = wfTimestamp(TS_ISO_8601, $tmp); - - // Comment - @ $tmp = $row-> { $prefix . '_comment' }; - if (!empty ($tmp)) // optimize bandwidth - $vals['comment'] = $tmp; - - return $vals; - } - - protected static function addTitleInfo(&$arr, $title, $prefix='') { - $arr[$prefix . 'ns'] = $title->getNamespace(); - $arr[$prefix . 'title'] = $title->getPrefixedText(); if (!$title->userCanRead()) $arr[$prefix . 'inaccessible'] = ""; } - private static function addRowInfo_title($row, $nsfld, $titlefld) { - if ( isset( $row-> $nsfld ) ) { - $ns = $row-> $nsfld; - @ $title = $row-> $titlefld; - if (!empty ($title)) - return Title :: makeTitle($ns, $title); - } - return false; - } - /** * Override this method to request extra fields from the pageSet * using $this->getPageSet()->requestField('fieldName') diff --git a/includes/api/ApiQueryCategories.php b/includes/api/ApiQueryCategories.php index 4b1acd41ad..d71005bf9c 100644 --- a/includes/api/ApiQueryCategories.php +++ b/includes/api/ApiQueryCategories.php @@ -99,7 +99,7 @@ class ApiQueryCategories extends ApiQueryGeneratorBase { // and category is listed there. $vals = array(); - ApiQueryBase :: addTitleInfo($vals, $title); + ApiQueryBase :: addTitleInfo($vals, $title, true); if ($fld_sortkey) $vals['sortkey'] = $row->cl_sortkey; diff --git a/includes/api/ApiQueryImages.php b/includes/api/ApiQueryImages.php index 558e512fe7..5e5fa5ec43 100644 --- a/includes/api/ApiQueryImages.php +++ b/includes/api/ApiQueryImages.php @@ -82,7 +82,7 @@ class ApiQueryImages extends ApiQueryGeneratorBase { // and images are listed there. $vals = array(); - ApiQueryBase :: addTitleInfo($vals, $title); + ApiQueryBase :: addTitleInfo($vals, $title, true); $data[] = $vals; } diff --git a/includes/api/ApiQueryLinks.php b/includes/api/ApiQueryLinks.php index 46549ca2b0..63ce418f1e 100644 --- a/includes/api/ApiQueryLinks.php +++ b/includes/api/ApiQueryLinks.php @@ -101,7 +101,7 @@ class ApiQueryLinks extends ApiQueryGeneratorBase { $title = Title :: makeTitle($row->pl_namespace, $row->pl_title); $vals = array(); - ApiQueryBase :: addTitleInfo($vals, $title); + ApiQueryBase :: addTitleInfo($vals, $title, true); $data[] = $vals; } diff --git a/includes/api/ApiQueryLogEvents.php b/includes/api/ApiQueryLogEvents.php index 0ae53843cb..35185b8327 100644 --- a/includes/api/ApiQueryLogEvents.php +++ b/includes/api/ApiQueryLogEvents.php @@ -125,7 +125,7 @@ class ApiQueryLogEvents extends ApiQueryBase { if (isset ($params[0])) { $title = Title :: newFromText($params[0]); if ($title) { - ApiQueryBase :: addTitleInfo($vals, $title, "new_"); + ApiQueryBase :: addTitleInfo($vals, $title, false, "new_"); $params = null; } } diff --git a/includes/api/ApiQueryRecentChanges.php b/includes/api/ApiQueryRecentChanges.php index 93886341f4..5a9f973f08 100644 --- a/includes/api/ApiQueryRecentChanges.php +++ b/includes/api/ApiQueryRecentChanges.php @@ -40,6 +40,10 @@ class ApiQueryRecentChanges extends ApiQueryBase { parent :: __construct($query, $moduleName, 'rc'); } + private $fld_comment = false, $fld_user = false, $fld_flags = false, + $fld_timestamp = false, $fld_title = false, $fld_ids = false, + $fld_sizes = false; + public function execute() { $limit = $prop = $namespace = $show = $dir = $start = $end = null; extract($this->extractRequestParams()); @@ -65,9 +69,6 @@ class ApiQueryRecentChanges extends ApiQueryBase { 'rc_timestamp', 'rc_namespace', 'rc_title', - 'rc_cur_id', - 'rc_this_oldid', - 'rc_last_oldid', 'rc_type', 'rc_moved_to_ns', 'rc_moved_to_title' @@ -75,16 +76,26 @@ class ApiQueryRecentChanges extends ApiQueryBase { if (!is_null($prop)) { $prop = array_flip($prop); - $this->addFieldsIf('rc_comment', isset ($prop['comment'])); - if (isset ($prop['user'])) { - $this->addFields('rc_user'); - $this->addFields('rc_user_text'); - } - if (isset ($prop['flags'])) { - $this->addFields('rc_minor'); - $this->addFields('rc_bot'); - $this->addFields('rc_new'); - } + + $this->fld_comment = isset ($prop['comment']); + $this->fld_user = isset ($prop['user']); + $this->fld_flags = isset ($prop['flags']); + $this->fld_timestamp = isset ($prop['timestamp']); + $this->fld_title = isset ($prop['title']); + $this->fld_ids = isset ($prop['ids']); + $this->fld_sizes = isset ($prop['sizes']); + + $this->addFieldsIf('rc_cur_id', $this->fld_ids); + $this->addFieldsIf('rc_this_oldid', $this->fld_ids); + $this->addFieldsIf('rc_last_oldid', $this->fld_ids); + $this->addFieldsIf('rc_comment', $this->fld_comment); + $this->addFieldsIf('rc_user', $this->fld_user); + $this->addFieldsIf('rc_user_text', $this->fld_user); + $this->addFieldsIf('rc_minor', $this->fld_flags); + $this->addFieldsIf('rc_bot', $this->fld_flags); + $this->addFieldsIf('rc_new', $this->fld_flags); + $this->addFieldsIf('rc_old_len', $this->fld_sizes); + $this->addFieldsIf('rc_new_len', $this->fld_sizes); } $this->addOption('LIMIT', $limit +1); @@ -94,6 +105,7 @@ class ApiQueryRecentChanges extends ApiQueryBase { $count = 0; $db = $this->getDB(); $res = $this->select(__METHOD__); + while ($row = $db->fetchObject($res)) { if (++ $count > $limit) { // We've reached the one extra which shows that there are additional pages to be had. Stop here... @@ -101,8 +113,8 @@ class ApiQueryRecentChanges extends ApiQueryBase { break; } - $vals = $this->addRowInfo('rc', $row); - if ($vals) + $vals = $this->extractRowInfo($row); + if($vals) $data[] = $vals; } $db->freeResult($res); @@ -112,6 +124,69 @@ class ApiQueryRecentChanges extends ApiQueryBase { $result->addValue('query', $this->getModuleName(), $data); } + /** + * Security overview: As implemented, any change to a restricted page (userCanRead() == false) + * is hidden from the client, except when a page is being moved to a non-restricted name, + * or when a non-restricted becomes restricted. When shown, all other fields are shown as well. + */ + private function extractRowInfo($row) { + $title = Title :: makeTitle($row->rc_namespace, $row->rc_title); + $movedToTitle = false; + if (!empty($row->rc_moved_to_title)) + $movedToTitle = Title :: makeTitle($row->rc_moved_to_ns, $row->rc_moved_to_title); + + // If either this is an edit of a restricted page, + // or a move where both to and from names are restricted, skip + if (!$title->userCanRead() && (!$movedToTitle || + ($movedToTitle && !$movedToTitle->userCanRead()))) + return false; + + $vals = array (); + + $vals['type'] = intval($row->rc_type); + + if ($this->fld_title) { + ApiQueryBase :: addTitleInfo($vals, $title); + if ($movedToTitle) + ApiQueryBase :: addTitleInfo($vals, $movedToTitle, false, "new_"); + } + + if ($this->fld_ids) { + $vals['pageid'] = intval($row->rc_cur_id); + $vals['revid'] = intval($row->rc_this_oldid); + $vals['old_revid'] = intval( $row->rc_last_oldid ); + } + + if ($this->fld_user) { + $vals['user'] = $row->rc_user_text; + if(!$row->rc_user) + $vals['anon'] = ''; + } + + if ($this->fld_flags) { + if ($row->rc_bot) + $vals['bot'] = ''; + if ($row->rc_new) + $vals['new'] = ''; + if ($row->rc_minor) + $vals['minor'] = ''; + } + + if ($this->fld_sizes) { + $vals['oldlen'] = intval($row->rc_old_len); + $vals['newlen'] = intval($row->rc_new_len); + } + + if ($this->fld_timestamp) + $vals['timestamp'] = wfTimestamp(TS_ISO_8601, $row->rc_timestamp); + + if ($this->fld_comment && !empty ($row->rc_comment)) { + $vals['comment'] = $row->rc_comment; + } + + return $vals; + } + protected function getAllowedParams() { return array ( 'start' => array ( @@ -133,10 +208,15 @@ class ApiQueryRecentChanges extends ApiQueryBase { ), 'prop' => array ( ApiBase :: PARAM_ISMULTI => true, + ApiBase :: PARAM_DFLT => 'title|timestamp|ids', ApiBase :: PARAM_TYPE => array ( 'user', 'comment', - 'flags' + 'flags', + 'timestamp', + 'title', + 'ids', + 'sizes' ) ), 'show' => array ( diff --git a/includes/api/ApiQueryRevisions.php b/includes/api/ApiQueryRevisions.php index 5f238d6134..97200c731e 100644 --- a/includes/api/ApiQueryRevisions.php +++ b/includes/api/ApiQueryRevisions.php @@ -203,7 +203,7 @@ class ApiQueryRevisions extends ApiQueryBase { $vals = array (); - $vals['revid'] = intval( $row->rev_id ); + $vals['revid'] = intval($row->rev_id); $vals['pageid'] = intval($row->rev_page); $vals['oldid'] = intval($row->rev_text_id); diff --git a/includes/api/ApiQueryUserContributions.php b/includes/api/ApiQueryUserContributions.php index 73a39a2cdb..67efebdf87 100644 --- a/includes/api/ApiQueryUserContributions.php +++ b/includes/api/ApiQueryUserContributions.php @@ -40,7 +40,8 @@ class ApiQueryContributions extends ApiQueryBase { } private $params, $userTitle; - private $fld_title = false, $fld_timestamp = false, $fld_comment = false, $fld_flags = false; + private $fld_ids = false, $fld_title = false, $fld_timestamp = false, + $fld_comment = false, $fld_flags = false; public function execute() { @@ -50,10 +51,11 @@ class ApiQueryContributions extends ApiQueryBase { if (!is_null($prop)) { $prop = array_flip($prop); + $this->fld_ids = isset($prop['ids']); $this->fld_title = isset($prop['title']); - $this->fld_comment = isset ($prop['comment']); - $this->fld_flags = isset ($prop['flags']); - $this->fld_timestamp = isset ($prop['timestamp']); + $this->fld_comment = isset($prop['comment']); + $this->fld_flags = isset($prop['flags']); + $this->fld_timestamp = isset($prop['timestamp']); } // TODO: if the query is going only against the revision table, should this be done? @@ -122,14 +124,10 @@ class ApiQueryContributions extends ApiQueryBase { */ private function prepareQuery() { - if ($this->fld_title || $this->fld_flags) { - //We're after the revision table, and the corresponding page row for - //anything we retrieve. - list ($tbl_page, $tbl_revision) = $this->getDB()->tableNamesN('page', 'revision'); - $this->addTables("$tbl_revision LEFT OUTER JOIN $tbl_page ON page_id=rev_page"); - } else { - $this->addTables("revision"); - } + //We're after the revision table, and the corresponding page row for + //anything we retrieve. + list ($tbl_page, $tbl_revision) = $this->getDB()->tableNamesN('page', 'revision'); + $this->addTables("$tbl_revision LEFT OUTER JOIN $tbl_page ON page_id=rev_page"); // We only want pages by the specified user. $this->addWhereFld('rev_user_text', $this->userTitle->getText()); @@ -150,23 +148,22 @@ class ApiQueryContributions extends ApiQueryBase { $this->addOption('LIMIT', $this->params['limit'] + 1); - // We want to know the namespace, title, new-ness, and ID of a page, - // and the id, text-id, timestamp, minor-status, summary and page - // of a revision. + // Mandatory fields: timestamp allows request continuation + // ns+title checks if the user has access rights for this page $this->addFields(array( - 'rev_page', - 'rev_id', - 'rev_timestamp', // Always include timestamp to enable request continuation - // 'rev_text_id', // Should this field be exposed? + 'rev_timestamp', + 'page_namespace', + 'page_title', )); + $this->addFieldsIf('rev_page', $this->fld_ids); + $this->addFieldsIf('rev_id', $this->fld_ids); + // $this->addFieldsIf('rev_text_id', $this->fld_ids); // Should this field be exposed? $this->addFieldsIf('rev_comment', $this->fld_comment); $this->addFieldsIf('rev_minor_edit', $this->fld_flags); // These fields depend only work if the page table is joined $this->addFieldsIf('page_is_new', $this->fld_flags); - $this->addFieldsIf('page_namespace', $this->fld_title); - $this->addFieldsIf('page_title', $this->fld_title); } /** @@ -180,15 +177,15 @@ class ApiQueryContributions extends ApiQueryBase { $vals = array(); - $vals['pageid'] = intval($row->rev_page); - $vals['revid'] = intval($row->rev_id); - + if ($this->fld_ids) { + $vals['pageid'] = intval($row->rev_page); + $vals['revid'] = intval($row->rev_id); + // $vals['textid'] = intval($row->rev_text_id); // todo: Should this field be exposed? + } + if ($this->fld_title) ApiQueryBase :: addTitleInfo($vals, $title); - // Should this field be exposed? -// $vals['textid'] = intval($row->rev_text_id); - if ($this->fld_timestamp) $vals['timestamp'] = wfTimestamp(TS_ISO_8601, $row->rev_timestamp); @@ -232,9 +229,9 @@ class ApiQueryContributions extends ApiQueryBase { ), 'prop' => array ( ApiBase :: PARAM_ISMULTI => true, - ApiBase :: PARAM_DFLT => 'title|timestamp|flags|comment', + ApiBase :: PARAM_DFLT => 'ids|title|timestamp|flags|comment', ApiBase :: PARAM_TYPE => array ( - '', + 'ids', 'title', 'timestamp', 'comment', diff --git a/includes/api/ApiQueryWatchlist.php b/includes/api/ApiQueryWatchlist.php index 5ffe6b0813..4b17e4e324 100644 --- a/includes/api/ApiQueryWatchlist.php +++ b/includes/api/ApiQueryWatchlist.php @@ -48,7 +48,7 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { $this->run($resultPageSet); } - private $fld_patrol = false, $fld_flags = false, $fld_timestamp = false, $fld_user = false, $fld_comment = false; + private $fld_ids = false, $fld_title = false, $fld_patrol = false, $fld_flags = false, $fld_timestamp = false, $fld_user = false, $fld_comment = false; private function run($resultPageSet = null) { global $wgUser; @@ -63,10 +63,12 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { if (!is_null($prop)) { if (!is_null($resultPageSet)) - $this->dieUsage('prop parameter may not be used in a generator', 'params'); + $this->dieUsage($this->encodeParamName('prop') . ' parameter may not be used in a generator', 'params'); $prop = array_flip($prop); + $this->fld_ids = isset($prop['ids']); + $this->fld_title = isset($prop['title']); $this->fld_flags = isset($prop['flags']); $this->fld_user = isset($prop['user']); $this->fld_comment = isset($prop['comment']); @@ -83,7 +85,7 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { if (is_null($resultPageSet)) { $this->addFields(array ( 'rc_cur_id', - // 'rc_this_oldid', // Should this field be exposed? + 'rc_this_oldid', 'rc_namespace', 'rc_title', 'rc_timestamp' @@ -182,10 +184,13 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { $vals = array (); - $vals['pageid'] = intval($row->rc_cur_id); - // $vals['textid'] = intval($row->rc_this_oldid); // Should this field be exposed? - - ApiQueryBase :: addTitleInfo($vals, $title); + if ($this->fld_ids) { + $vals['pageid'] = intval($row->rc_cur_id); + $vals['revid'] = intval($row->rc_this_oldid); + } + + if ($this->fld_title) + ApiQueryBase :: addTitleInfo($vals, $title); if ($this->fld_user) { $vals['user'] = $row->rc_user_text; @@ -241,7 +246,10 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { ), 'prop' => array ( APIBase :: PARAM_ISMULTI => true, + APIBase :: PARAM_DFLT => 'ids|title|flags', APIBase :: PARAM_TYPE => array ( + 'ids', + 'title', 'flags', 'user', 'comment', @@ -270,8 +278,9 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { protected function getExamples() { return array ( - 'api.php?action=query&list=watchlist&wlprop=timestamp|user|comment', - 'api.php?action=query&list=watchlist&wlallrev&wlprop=timestamp|user|comment', + 'api.php?action=query&list=watchlist', + 'api.php?action=query&list=watchlist&wlprop=ids|title|timestamp|user|comment', + 'api.php?action=query&list=watchlist&wlallrev&wlprop=ids|title|timestamp|user|comment', 'api.php?action=query&generator=watchlist&prop=info', 'api.php?action=query&generator=watchlist&gwlallrev&prop=revisions&rvprop=timestamp|user' ); -- 2.20.1