jenkins-bot has submitted this change and it was merged.

Change subject: Implement flagging backend
......................................................................


Implement flagging backend

* add a new table (gather_list_flag) which tracks which user
  flagged which list
* add gl_perm_override to track approved/hidden status
* add gl_needs_review to track unreviewed text changes
* add flag/approve modes to the editlist API
* when list is approved, flags become processed - they still prevent
  further flags from that user/ip but otherwise have no effect
* when label or description is edited, approved becomes public,
  and hidden is flagged as needing review
* hide items with >3 unreviewed flags from public lists, unless they
  are approved (limit configurable via $wgGatherAutohideFlagLimit)
* create a review queue (lists API mode=review) which shows lists with
  3+ unreviewed flags or a gl_needs_review flag
* expose gl_perm_override and gl_needs_review fields to lists API and
  add some derived properties (flagged, hidden)
* add Special:Gather/all/review as a web interface to the review queue
* add UserMerge hooks for gather_list_flag

Bug: T97704
Change-Id: Iae85317f9560d80475b53a66691b474981c13668
---
M extension.json
M i18n/en.json
M i18n/qqq.json
M includes/Gather.hooks.php
M includes/api/ApiEditList.php
M includes/api/ApiMixinListAccess.php
M includes/api/ApiQueryLists.php
M includes/models/CollectionsList.php
M includes/specials/SpecialGather.php
M schema/Updater.hooks.php
A schema/archive/add-gl_list-flag-columns.sql
M schema/gather_list.sql
A schema/gather_list_flag.sql
M tests/phpunit/api/ApiEditListTest.php
M tests/phpunit/api/ApiQueryListPagesTest.php
M tests/phpunit/api/ApiQueryListsTest.php
M tests/phpunit/api/GatherTest.php
M tests/phpunit/api/GatherTestCase.php
18 files changed, 551 insertions(+), 85 deletions(-)

Approvals:
  Gergő Tisza: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/extension.json b/extension.json
index 2dd3f73..cfb0efa 100644
--- a/extension.json
+++ b/extension.json
@@ -722,7 +722,8 @@
                "GatherShouldShowTutorial": true,
                "GatherAllowPublicWatchlist": false,
                "GatherEnableBetaFeature": false,
-               "GatherRecompileTemplates": false
+               "GatherRecompileTemplates": false,
+               "GatherAutohideFlagLimit": 1000000
        },
        "manifest_version": 1
 }
diff --git a/i18n/en.json b/i18n/en.json
index e9e487a..3b18cda 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -110,6 +110,9 @@
        "gather-moderation-unhidden": "[[$1|Your collection]] was unhidden. 
This means that it can be viewed publicly again. ",
        "gather-moderation-unhidden-email-subject": "Your collection $1 was 
unhidden.",
        "gather-moderation-unhidden-email-body": "Your collection $1 was 
unhidden. This means that it can be viewed publicly again.",
+       "gather-moderation-approved": "[[$1|Your collection]] was approved. 
This means that it won't be automatically hidden again.",
+       "gather-moderation-approved-email-subject": "Your collection $1 was 
approved.",
+       "gather-moderation-approved-email-body": "Your collection $1 was 
approved. This means that it won't be automatically hidden again.",
        "echo-category-title-gather": "Gather",
        "gather-echo-pref-tooltip": "Notify me about changes to my 
collections.",
        "gather-article-count": "$1 {{PLURAL:$1|page|pages}}",
@@ -140,6 +143,8 @@
        "apihelp-editlist-paramvalue-mode-deletelist": "Delete the whole list.",
        "apihelp-editlist-paramvalue-mode-hidelist": "Make the list private. 
Requires the <kbd>gather-hidelist</kbd> user right (and cannot be undone 
without that right).",
        "apihelp-editlist-paramvalue-mode-showlist": "Undo a previous hidelist 
operation.",
+       "apihelp-editlist-paramvalue-mode-flag": "Flag list as offensive.",
+       "apihelp-editlist-paramvalue-mode-approve": "Mark list as acceptable, 
despite any flags. Requires the <kbd>gather-hidelist</kbd> userright.",
        "apihelp-query+lists-description": "List collections of a given user.",
        "apihelp-query+lists-param-mode": "Show all lists matching certain 
criteria (cannot be used with owner/id parameters; if neither those nor mode is 
provided, only the lists of the current user are shown).",
        "apihelp-query+lists-param-prop": "Extra information to display.",
@@ -149,8 +154,11 @@
        "apihelp-query+lists-param-limit": "Limit the number of returned 
lists.",
        "apihelp-query+lists-paramvalue-mode-allpublic": "Show all (public) 
lists",
        "apihelp-query+lists-paramvalue-mode-allhidden": "Show all hidden lists 
(requires the <kbd>gather-hidelist</kbd> user right; does not show private 
lists)",
+       "apihelp-query+lists-paramvalue-mode-review": "Show all lists that need 
review (requires the <kbd>gather-hidelist</kbd> userright)",
        "apihelp-query+lists-paramvalue-prop-label": "Title of the list",
        "apihelp-query+lists-paramvalue-prop-description": "Description of the 
list",
+       "apihelp-query+lists-paramvalue-prop-public": "Several 
visibility-related properties: perm - <kbd>public</kbd> or <kbd>private</kbd> 
(set by owner), private is only visible to owner; perm_override - 
<kbd>hidden</kbd> or <kbd>approved</kbd> (or not present), can be set by a 
moderator; flagged - the list got enough flags to qualify for moderator 
attention, hidden - the list is hidden and only visible to the owenr and 
moderators (due to flags or explicit moderator choice)",
+       "apihelp-query+lists-paramvalue-prop-review": "Is the list temporarily 
hidden and waiting for moderator review?",
        "apihelp-query+lists-paramvalue-prop-image": "Lead image",
        "apihelp-query+lists-paramvalue-prop-count": "Number of items in the 
list",
        "apihelp-query+lists-paramvalue-prop-updated": "Timestamp of last 
update",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 22a893a..11239af 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -111,6 +111,9 @@
        "gather-moderation-unhidden": "Label for echo notification explaining 
that your collection was unhidden and that it can be viewed again as normal. 
Parameters:\n* $1 - Url of collection.",
        "gather-moderation-unhidden-email-subject": "Label for echo 
notification email subject explaining that your collection was unhidden. 
Parameters:\n* $1 - Url of collection.",
        "gather-moderation-unhidden-email-body": "Label for echo notification 
email body explaining that your collection was unhidden. Parameters:\n* $1 - 
Url of collection.",
+       "gather-moderation-approved": "Label for echo notification explaining 
that your collection was approved (ie. it won't be automatically hidden when a 
lot of users flag it). Parameters:\n* $1 - Url of collection.",
+       "gather-moderation-approved-email-subject": "Label for echo 
notification email subject explaining that your collection was approved. 
Parameters:\n* $1 - Url of collection.",
+       "gather-moderation-approved-email-body": "Label for echo notification 
email body explaining that your collection was approved. Parameters:\n* $1 - 
Url of collection.",
        "echo-category-title-gather": "A label for the Gather checkboxes in the 
Echo notifications preferences.",
        "gather-echo-pref-tooltip": "This is a short description of the gather 
notification category in the Echo notifications 
preferences.\n{{Related|Echo-pref-tooltip}}",
        "gather-article-count": "Expression of the number of pages in a 
collection. Parameter:\n* $1 - number of pages in the 
collection\n{{Identical|Page}}",
@@ -141,6 +144,8 @@
        "apihelp-editlist-paramvalue-mode-deletelist": 
"{{doc-apihelp-paramvalue||mode|editlist|editlist|deletelist}}",
        "apihelp-editlist-paramvalue-mode-hidelist": 
"{{doc-apihelp-paramvalue||mode|editlist|editlist|hidelist}}",
        "apihelp-editlist-paramvalue-mode-showlist": 
"{{doc-apihelp-paramvalue||mode|editlist|editlist|showlist}}",
+       "apihelp-editlist-paramvalue-mode-flag": 
"{{doc-apihelp-paramvalue||mode|editlist|editlist|flag}}",
+       "apihelp-editlist-paramvalue-mode-approve": 
"{{doc-apihelp-paramvalue||mode|editlist|editlist|approve}}",
        "apihelp-query+lists-description": 
"{{doc-apihelp-description|lst|lists|query+lists}}",
        "apihelp-query+lists-param-mode": 
"{{doc-apihelp-param|lst|mode|lists|query+lists}}",
        "apihelp-query+lists-param-prop": 
"{{doc-apihelp-param|lst|prop|lists|query+lists}}",
@@ -150,8 +155,11 @@
        "apihelp-query+lists-param-limit": 
"{{doc-apihelp-param|lst|limit|lists|query+lists}}",
        "apihelp-query+lists-paramvalue-mode-allpublic": 
"{{doc-apihelp-paramvalue|lst|mode|lists|query+lists|allpublic}}",
        "apihelp-query+lists-paramvalue-mode-allhidden": 
"{{doc-apihelp-paramvalue|lst|mode|lists|query+lists|allhidden}}",
+       "apihelp-query+lists-paramvalue-mode-review": 
"{{doc-apihelp-paramvalue|lst|mode|lists|query+lists|review}}",
        "apihelp-query+lists-paramvalue-prop-label": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|label}}",
        "apihelp-query+lists-paramvalue-prop-description": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|description}}",
+       "apihelp-query+lists-paramvalue-prop-public": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|public}}",
+       "apihelp-query+lists-paramvalue-prop-review": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|review}}",
        "apihelp-query+lists-paramvalue-prop-image": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|image}}",
        "apihelp-query+lists-paramvalue-prop-count": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|count}}",
        "apihelp-query+lists-paramvalue-prop-updated": 
"{{doc-apihelp-paramvalue|lst|prop|lists|query+lists|updated}}",
diff --git a/includes/Gather.hooks.php b/includes/Gather.hooks.php
index f66d87b..d21196c 100644
--- a/includes/Gather.hooks.php
+++ b/includes/Gather.hooks.php
@@ -13,6 +13,7 @@
 use PageImages;
 use BetaFeatures;
 use User;
+use EchoEvent;
 
 /**
  * Hook handlers for Gather extension
@@ -87,6 +88,17 @@
                        'email-body-batch-params' => array( 'title' ),
                );
 
+               $notifications['gather-approve'] = array(
+                       'category' => 'gather',
+                       'group' => 'positive',
+                       'title-message' => 'gather-moderation-approved',
+                       'title-params' => array( 'title' ),
+                       'email-subject-message' => 
'gather-moderation-approved-email-subject',
+                       'email-subject-params' => array( 'title' ),
+                       'email-body-batch-message' => 
'gather-moderation-approved-email-batch-body',
+                       'email-body-batch-params' => array( 'title' ),
+               );
+
                return true;
        }
 
@@ -100,6 +112,7 @@
                switch ( $event->getType() ) {
                        case 'gather-hide':
                        case 'gather-unhide':
+                       case 'gather-approve':
                                $extra = $event->getExtra();
                                if ( !$extra || !isset( 
$extra['collection-owner-id'] ) ) {
                                        break;
@@ -339,6 +352,8 @@
        public static function onUserMergeAccountFields( array &$updateFields ) 
{
                $updateFields[] = array( 'gather_list', 'gl_user', 'batchKey' 
=> 'gl_id',
                        'options' => array( 'IGNORE' ) );
+               $updateFields[] = array( 'gather_list_flag', 'glf_user_id', 
'batchKey' => 'glf_gl_id',
+                       'options' => array( 'IGNORE' ) );
        }
 
        /**
@@ -364,5 +379,8 @@
                        array( 'gl_user' => $newId, "gl_label = 
CONCAT(gl_label, $disambiguator )" ),
                        array( 'gl_user' => $oldId ),
                __METHOD__, array( 'IGNORE' ) );
+
+               // If both users flagged a list, just discard the flags made by 
the old user.
+               $dbw->delete( 'gather_list_flag', array( 'glf_user_id' => 
$oldId ), __METHOD__ );
        }
 }
diff --git a/includes/api/ApiEditList.php b/includes/api/ApiEditList.php
index cf9729c..cd6c8f2 100644
--- a/includes/api/ApiEditList.php
+++ b/includes/api/ApiEditList.php
@@ -26,6 +26,7 @@
 
 namespace Gather\api;
 
+use Gather\models\Collection;
 use ManualLogEntry;
 use AbuseFilter;
 use AbuseFilterVariableHolder;
@@ -57,17 +58,51 @@
 
        const PERM_PRIVATE = 0;
        const PERM_PUBLIC = 1;
-       const PERM_HIDDEN = 2;
+       const PERM_OVERRIDE_NONE = 0;
+       const PERM_OVERRIDE_HIDDEN = 1; // like private but cannot be published 
by owner
+       const PERM_OVERRIDE_APPROVED = 2; // like public but cannot be 
autohidden
+
+       /**
+        * Maps API actions (perm parameter values) to PERM_* constants (DB 
values);
+        * also DB values to lists API properties
+        * @var array
+        */
+       public static $permMap = array(
+               'public' => self::PERM_PUBLIC,
+               'private' => self::PERM_PRIVATE,
+       );
+
+       /**
+        * Maps API actions (mode parameter values) to PERM_OVERRIDE_* 
constants (DB values)
+        * @var array
+        */
+       public static $permOverrideMap = array(
+               'hidelist' => self::PERM_OVERRIDE_HIDDEN,
+               'showlist' => self::PERM_OVERRIDE_NONE,
+               'approve' => self::PERM_OVERRIDE_APPROVED,
+       );
+
+       /**
+        * Maps API actions (mode parameter values) to Echo notification types
+        * @var array
+        */
+       public static $permOverrideNotificationMap = array(
+               'hidelist' => 'gather-hide',
+               'showlist' => 'gather-unhide',
+               'approve' => 'gather-approve',
+       );
+
 
        /**
         * @param string $type Log type
         * @param string $action Log action
         * @param Title|null $title Title object or null
-        * @param Skin|null $skin Skin object or null. If null, we want to use 
the wiki
+        * @param \Skin|null $skin Skin object or null. If null, we want to use 
the wiki
         *   content language, since that will go to the IRC feed.
         * @param array $params Parameters
+        * @return string
         */
-       public static function getGatherLogFormattedString( $type, $action, 
$title, $sk, $params ) {
+       public static function getGatherLogFormattedString( $type, $action, 
$title, $skin, $params ) {
                return wfMessage( 'gather-checkuser-log-action' )
                        ->rawParams( $params['action'], '[[' . 
$title->getPrefixedText() . ']]' )->parse();
        }
@@ -116,39 +151,78 @@
                                case 'deletelist':
                                        // ACTION: delete list (items + list 
itself)
                                        $dbw->delete( 'gather_list_item', 
array( 'gli_gl_id' => $listId ), __METHOD__ );
+                                       $dbw->delete( 'gather_list_flag', 
array( 'glf_gl_id' => $listId ), __METHOD__ );
                                        $dbw->delete( 'gather_list', array( 
'gl_id' => $listId ), __METHOD__ );
                                        $this->setResultStatus( $listId, 
'deleted' );
                                        $logEventName = $mode;
                                        break;
                                case 'hidelist':
                                case 'showlist':
-                                       $update = array();
-                                       $perm = $mode === 'showlist' ? 
self::PERM_PUBLIC : self::PERM_HIDDEN;
-                                       if ( $row->gl_perm !== $perm ) {
-                                               $update['gl_perm'] = $perm;
-                                       }
+                               case 'approve':
+                                       $update = array( 'gl_flag_count' => 0, 
'gl_needs_review' => 0 );
+                                       $permOverride = 
self::$permOverrideMap[$mode];
+                                       $update['gl_perm_override'] = 
$permOverride;
+                                       $update = array_diff_assoc( $update, 
(array)$row ); // remove fields with no changes
+
+                                       $dbw->begin( __METHOD__ );
                                        $this->updateRow( $dbw, $row, $update );
-                                       $logEventName = $mode;
-                                       // Do echo notification
-                                       if ( class_exists( 'EchoEvent' ) ) {
-                                               $eventType = $mode === 
'showlist' ? 'gather-unhide' : 'gather-hide';
-                                               // FIXME: better long term 
solution for generating collection urls needed
-                                               // Model currently handles it 
which is not accessible from here
-                                               $collectionTitle = 
SpecialPage::getTitleFor( 'Gather' )
-                                                       ->getSubpage( 'id' )
-                                                       ->getSubpage( 
$row->gl_id )
-                                                       ->getSubpage( 
$row->gl_label );
-
-                                               EchoEvent::create( array(
-                                                       'type' => $eventType,
-                                                       'title' => 
$collectionTitle,
-                                                       'extra' => array(
-                                                               
'collection-owner-id' => $row->gl_user,
-                                                       ),
-                                                       'agent' => $user,
-                                               ) );
-
+                                       if ( $dbw->affectedRows() ) {
+                                               $logEventName = $mode;
                                        }
+                                       $dbw->update( 'gather_list_flag',
+                                               array( 'glf_reviewed' => 1 ),
+                                               array( 'glf_gl_id' => $listId ),
+                                       __METHOD__ );
+                                       $dbw->commit( __METHOD__ );
+
+                                       if ( $logEventName ) {
+                                               // do echo notification, unless 
the action was a noop
+                                               if ( class_exists( 'EchoEvent' 
) && in_array( $mode, self::$permOverrideNotificationMap ) ) {
+                                                       $eventType = 
self::$permOverrideNotificationMap[$mode];
+                                                       // FIXME: better long 
term solution for generating collection urls needed
+                                                       // Model currently 
handles it which is not accessible from here
+                                                       $collectionTitle = 
SpecialPage::getTitleFor( 'Gather' )
+                                                               ->getSubpage( 
'id' )
+                                                               ->getSubpage( 
$row->gl_id )
+                                                               ->getSubpage( 
$row->gl_label );
+
+                                                       EchoEvent::create( 
array(
+                                                               'type' => 
$eventType,
+                                                               'title' => 
$collectionTitle,
+                                                               'extra' => 
array(
+                                                                       
'collection-owner-id' => $row->gl_user,
+                                                               ),
+                                                               'agent' => 
$user,
+                                                       ) );
+                                               }
+                                       }
+                                       break;
+                               case 'flag':
+                                       $dbw->begin( __METHOD__ );
+                                       // lock list to avoid race condition 
with a show/hide/approve
+                                       $dbw->select( 'gather_list', 'gl_id', 
array( 'gl_id' => $listId ), __METHOD__,
+                                               array( 'FOR UPDATE' ) );
+                                       $dbw->insert( 'gather_list_flag',
+                                               array(
+                                                       'glf_user_id' => 
$user->getId(),
+                                                       'glf_user_ip' => 
$user->getId() ? '' : $this->getRequest()->getIP(),
+                                                       'glf_gl_id' => $listId,
+                                               ),
+                                               __METHOD__,
+                                               array( 'IGNORE' )
+                                       );
+                                       if ( !$dbw->affectedRows() ) {
+                                               $dbw->rollback( __METHOD__ );
+                                               $this->dieUsage( "List already 
flagged by user",
+                                                       'alreadyflagged' );
+                                       }
+                                       $dbw->update( 'gather_list',
+                                               array( 'gl_flag_count = 
gl_flag_count + 1' ),
+                                               array( 'gl_id' => $listId ),
+                                               __METHOD__ );
+                                       $dbw->commit( __METHOD__ );
+                                       $this->setResultStatus( $row->gl_id, 
'flagged' );
+                                       $logEventName = $mode;
                                        break;
                        }
                }
@@ -187,8 +261,8 @@
                        \CheckUserHooks::updateCheckUserData( $rc );
                }
 
-               // Surface hide and unhide actions in Special:Log
-               if ( $action === 'hidelist' || $action === 'showlist' ) {
+               // Surface hide, unhide and approve actions in Special:Log
+               if ( $action === 'hidelist' || $action === 'showlist' || 
$action === 'approve' ) {
                        $logId = $entry->insert();
                        $entry->publish( $logId, 'udp' );
                }
@@ -251,11 +325,12 @@
                $label = $params['label'];
                // These modes cannot change list items or change other params 
like label/description/...
                // Incidentally, these are also modes that cannot be applied to 
the watchlist
-               $isNoUpdatesMode = in_array( $mode, array( 'showlist', 
'hidelist', 'deletelist' ) );
+               $isNoUpdatesMode = in_array( $mode,
+                       array( 'showlist', 'hidelist', 'deletelist', 'approve', 
'flag' ) );
 
-               if ( !$user->isLoggedIn() ) {
+               if ( !$user->isLoggedIn() && $mode !== 'flag' ) {
                        $this->dieUsage( 'You must be logged-in to edit a 
list', 'notloggedin' );
-               } elseif ( !$user->isAllowed( 'editmywatchlist' ) ) {
+               } elseif ( !$user->isAllowed( 'editmywatchlist' ) && $mode !== 
'flag' ) {
                        $this->dieUsage( 'You don\'t have permission to edit 
your list', 'permissiondenied' );
                } elseif ( $user->isBlocked() ) {
                        $this->dieUsage( 'You are blocked from editing your 
list', 'blocked' );
@@ -281,15 +356,21 @@
                        }
                }
                if ( $isNew ) {
-                       if ( $isNoUpdatesMode ) {
+                       if ( $isNoUpdatesMode || $mode === 'remove' ) {
                                // These modes are not allowed for the new list 
(no ID)
                                $this->dieUsage( "List must be identified with 
{$p}id when {$p}mode=$mode is used",
                                        'invalidparammix' );
-                       } elseif ( $mode === 'remove' ) {
-                               $this->dieUsage( "List must be identified with 
{$p}id when {$p}mode=remove is used",
-                                       'invalidparammix' );
                        } elseif ( $label === null ) {
                                $this->dieUsage( "List {$p}label must be given 
for new lists", 'invalidparammix' );
+                       }
+               }
+               if ( $params['perm'] ) {
+                       if ( $mode !== 'update' ) {
+                               $this->dieUsage( "{$p}mode and {$p}perm cannot 
be used together", 'invalidparammix' );
+                       }
+                       if ( $row && $row->gl_perm_override !== 
self::PERM_OVERRIDE_NONE ) {
+                               $this->dieUsage( "Cannot change visibility when 
it is overriden by an admin",
+                                       'invalidparammix' );
                        }
                }
                switch ( $mode ) {
@@ -300,13 +381,10 @@
                                        $this->dieUsage( "List {$p}id does not 
belong to the current user",
                                                'permissiondenied' );
                                }
-                               if ( $row && $params['perm'] !== null && 
$row->gl_perm === self::PERM_HIDDEN ) {
-                                       $this->dieUsage( "Hidden list may not 
be made private or public",
-                                               'permissiondenied' );
-                               }
                                break;
                        case 'hidelist':
                        case 'showlist':
+                       case 'approve':
                                if ( !$user->isAllowed( 'gather-hidelist' ) ) {
                                        $this->dieUsage( "{$p}mode=$mode 
requires gather-hidelist permissions",
                                                'permissiondenied' );
@@ -314,8 +392,11 @@
                                if ( $row &&
                                         $row->gl_perm === self::PERM_PRIVATE 
&& $row->gl_user !== $user->getId()
                                ) {
-                                       $this->dieUsage( "Private list may not 
be made hidden", 'invalidparammix' );
+                                       $this->dieUsage( "Visibility of private 
list may only be changed by author",
+                                               'invalidparammix' );
                                }
+                               break;
+                       case 'flag':
                                break;
                        default:
                                $this->dieDebug( __METHOD__, 'Unknown mode=' . 
$mode );
@@ -400,6 +481,8 @@
                                        'deletelist',
                                        'hidelist',
                                        'showlist',
+                                       'flag',
+                                       'approve',
                                ),
                                ApiBase::PARAM_HELP_MSG_PER_VALUE => array(),
                        ),
@@ -422,11 +505,14 @@
         * Given an info object, update it with arguments from params, and 
return JSON str if changed
         * @param stdClass $v
         * @param Array $params
+        * @param bool $resetPermission true if the list has been edited in 
such a way that the
+        *   review status is reset (APPROVED becomes plain PUBLIC, HIDDEN 
becomes flagged for review)
         * @return string JSON encoded info object in case it changed, or NULL 
if update is not needed
         * @throws \UsageException
         */
-       private function updateInfo( stdClass $v, array $params ) {
+       private function updateInfo( stdClass $v, array $params, 
&$resetPermission = null ) {
                $updated = false;
+               $resetPermission = false;
 
                // Set default
                if ( !property_exists( $v, 'description' ) ) {
@@ -440,6 +526,7 @@
                if ( $params['description'] !== null && $v->description !== 
$params['description'] ) {
                        $v->description = $params['description'];
                        $updated = true;
+                       $resetPermission = true;
                }
                if ( $params['image'] !== null && $v->image !== 
$params['image'] ) {
                        if ( $params['image'] === '' ) {
@@ -483,6 +570,7 @@
                $label = $isWatchlist ? '' : $params['label'];
                $info = $this->updateInfo( new stdClass(), $params );
                $createRow = !$isWatchlist || $info || $params['perm'] === 
'public';
+               $perm = $params['perm'] ? self::$permMap[$params['perm']] : 
self::PERM_PRIVATE;
 
                if ( $createRow ) {
                        $id = $dbw->nextSequenceValue( 'gather_list_gl_id_seq' 
);
@@ -491,7 +579,7 @@
                                'gl_user' => $user->getId(),
                                'gl_label' => $label,
                                'gl_info' => $info,
-                               'gl_perm' => $params['perm'] === 'public' ? 
self::PERM_PUBLIC : self::PERM_PRIVATE,
+                               'gl_perm' => $perm,
                                'gl_updated' => $dbw->timestamp( 
wfTimestampNow() ),
                        ), __METHOD__, 'IGNORE' );
                        $id = $dbw->insertId();
@@ -531,12 +619,20 @@
        private function updateListDb( DatabaseBase $dbw, array $params, $row ) 
{
                $update = array();
                $info = self::parseListInfo( $row->gl_info, $row->gl_id, true );
-               $info = $this->updateInfo( $info, $params );
+               $info = $this->updateInfo( $info, $params, $resetPermission );
                if ( $info ) {
                        $update['gl_info'] = $info;
                }
                if ( $params['label'] !== null && $row->gl_label !== 
$params['label'] ) {
                        $update['gl_label'] = $params['label'];
+                       $resetPermission = true;
+               }
+               if ( $resetPermission ) {
+                       if ( $row->gl_perm_override === 
self::PERM_OVERRIDE_HIDDEN ) {
+                               $update['gl_needs_review'] = 1;
+                       } elseif ( $row->gl_perm_override === 
self::PERM_OVERRIDE_APPROVED ) {
+                               $update['gl_perm_override'] = 
self::PERM_OVERRIDE_NONE;
+                       }
                }
                if ( $params['perm'] !== null ) {
                        $perm = $params['perm'] === 'public' ?
@@ -719,7 +815,8 @@
         */
        private function getListRow( array $params, DatabaseBase $dbw, array 
$conds ) {
                $row = self::normalizeRow( $dbw->selectRow( 'gather_list',
-                       array( 'gl_id', 'gl_user', 'gl_label', 'gl_perm', 
'gl_info' ), $conds, __METHOD__ ) );
+                       array( 'gl_id', 'gl_user', 'gl_label', 'gl_perm', 
'gl_perm_override', 'gl_item_count',
+                                  'gl_flag_count', 'gl_needs_review', 
'gl_info' ), $conds, __METHOD__ ) );
                if ( $row ) {
                        $this->checkPermissions( $params, $row );
                }
@@ -737,6 +834,10 @@
                        self::normalizeInt( $row, 'gl_id' );
                        self::normalizeInt( $row, 'gl_user' );
                        self::normalizeInt( $row, 'gl_perm' );
+                       self::normalizeInt( $row, 'gl_perm_override' );
+                       self::normalizeInt( $row, 'gl_item_count' );
+                       self::normalizeInt( $row, 'gl_flag_count' );
+                       self::normalizeInt( $row, 'gl_needs_review' );
                }
                return $row;
        }
diff --git a/includes/api/ApiMixinListAccess.php 
b/includes/api/ApiMixinListAccess.php
index ddea2dc..dd02028 100644
--- a/includes/api/ApiMixinListAccess.php
+++ b/includes/api/ApiMixinListAccess.php
@@ -68,6 +68,8 @@
        public static function checkListAccess(
                DatabaseBase $db, ApiBase $module, array $params, 
&$isWatchlist, &$ownerId
        ) {
+               global $wgGatherAutohideFlagLimit;
+
                if ( is_null( $params['owner'] ) !== is_null( $params['token'] 
) ) {
                        $p = $module->getModulePrefix();
                        $module->dieUsage( "Both {$p}owner and {$p}token must 
be given or missing",
@@ -85,7 +87,8 @@
                // Id was given, this could be public or private list, legacy 
watchlist or regular
                // Allow access to any public list/watchlist, and to private 
with proper owner/self
                $listRow = $db->selectRow( 'gather_list',
-                       array( 'gl_label', 'gl_user', 'gl_perm' ),
+                       array( 'gl_label', 'gl_user', 'gl_perm', 
'gl_perm_override', 'gl_flag_count',
+                               'gl_needs_review' ),
                        array( 'gl_id' => $params['id'] ),
                        __METHOD__ );
                if ( $listRow === false ) {
@@ -112,7 +115,12 @@
                }
 
                // Check if this is a public list (if required)
-               if ( !$showPrivate && $listRow->gl_perm !== 
ApiEditList::PERM_PUBLIC ) {
+               if ( !$showPrivate && (
+                       $listRow->gl_perm !== ApiEditList::PERM_PUBLIC
+                       || $listRow->gl_perm_override === 
ApiEditList::PERM_OVERRIDE_HIDDEN
+                       || $listRow->gl_flag_count >= $wgGatherAutohideFlagLimit
+                          && $listRow->gl_perm_override !== 
ApiEditList::PERM_OVERRIDE_APPROVED
+               ) ) {
                        $module->dieUsage( 'You have no rights to see this 
list', 'badid' );
                }
 
diff --git a/includes/api/ApiQueryLists.php b/includes/api/ApiQueryLists.php
index cc6c104..e732f44 100644
--- a/includes/api/ApiQueryLists.php
+++ b/includes/api/ApiQueryLists.php
@@ -39,11 +39,23 @@
  * @ingroup API
  */
 class ApiQueryLists extends ApiQueryBase {
+       /**
+        * Maps DB values to API property values
+        * @var array
+        */
+       public static $permOverrideApiMap = array(
+               ApiEditList::PERM_OVERRIDE_NONE => '',
+               ApiEditList::PERM_OVERRIDE_HIDDEN => 'hidden',
+               ApiEditList::PERM_OVERRIDE_APPROVED => 'approved',
+       );
+
        public function __construct( ApiQuery $query, $moduleName ) {
                parent::__construct( $query, $moduleName, 'lst' );
        }
 
        public function execute() {
+               global $wgGatherAutohideFlagLimit;
+
                $p = $this->getModulePrefix();
                $params = $this->extractRequestParams();
                $continue = $params['continue'];
@@ -53,6 +65,7 @@
                $fld_label = in_array( 'label', $params['prop'] );
                $fld_description = in_array( 'description', $params['prop'] );
                $fld_public = in_array( 'public', $params['prop'] );
+               $fld_review = in_array( 'review', $params['prop'] );
                $fld_image = in_array( 'image', $params['prop'] );
                $fld_updated = in_array( 'updated', $params['prop'] );
                $fld_owner = in_array( 'owner', $params['prop'] );
@@ -111,6 +124,9 @@
                }
                $this->addFieldsIf( 'gl_updated', $fld_updated || $mode );
                $this->addFieldsIf( 'gl_perm', $fld_public );
+               $this->addFieldsIf( 'gl_perm_override', $fld_public );
+               $this->addFieldsIf( 'gl_flag_count', $fld_public );
+               $this->addFieldsIf( 'gl_needs_review', $fld_review );
 
                if ( $fld_owner && !$owner ) {
                        // Join user table to get user name
@@ -149,9 +165,33 @@
                }
 
                if ( $mode === 'allhidden' ) {
-                       $this->addWhereFld( 'gl_perm', ApiEditList::PERM_HIDDEN 
);
+                       // lists made private by their owners are not 
considered hidden
+                       $this->addWhereFld( 'gl_perm', ApiEditList::PERM_PUBLIC 
);
+                       $this->addWhere( $db->makeList( array(
+                               'gl_perm_override' => 
ApiEditList::PERM_OVERRIDE_HIDDEN,
+                               $db->makeList( array(
+                                       'gl_flag_count >= ' . 
$wgGatherAutohideFlagLimit,
+                                       'gl_perm_override != ' . 
ApiEditList::PERM_OVERRIDE_APPROVED,
+                               ), LIST_AND ),
+                       ), LIST_OR ) );
+               } elseif ( $mode === 'review' ) {
+                       $this->addWhereFld( 'gl_perm', ApiEditList::PERM_PUBLIC 
);
+                       $this->addWhere( $db->makeList( array(
+                               'gl_needs_review' => 1,
+                               'gl_flag_count >= ' . 
$wgGatherAutohideFlagLimit,
+                       ), LIST_OR ) );
+
                } elseif ( $showPrivate !== true ) {
-                       $cond = array( 'gl_perm' => ApiEditList::PERM_PUBLIC );
+                       $cond = array();
+                       $cond[] = $db->makeList( array(
+                               'gl_perm' => ApiEditList::PERM_PUBLIC,
+                               'gl_perm_override' => 
ApiEditList::PERM_OVERRIDE_APPROVED,
+                       ), LIST_AND );
+                       $cond[] = $db->makeList( array(
+                               'gl_perm' => ApiEditList::PERM_PUBLIC,
+                               'gl_perm_override != ' . 
ApiEditList::PERM_OVERRIDE_HIDDEN,
+                               'gl_flag_count < ' . $wgGatherAutohideFlagLimit,
+                       ), LIST_AND );
                        if ( $showPrivate === null ) {
                                $cond['gl_user'] = $this->getUser()->getId();
                        }
@@ -243,8 +283,8 @@
                                        // The very first DB row already has a 
label, so inject a fake
                                        if ( !$this->processRow(
                                                null, $count, $mode, $limit, 
$useInfo, $title, $fld_label,
-                                               $fld_description, $fld_public, 
$fld_image, $fld_updated, $fld_owner, $path,
-                                               $owner, 
$getContinueEnumParameter
+                                               $fld_description, $fld_public, 
$fld_review, $fld_image, $fld_updated,
+                                               $fld_owner, $path, $owner, 
$getContinueEnumParameter
                                        ) ) {
                                                break;
                                        }
@@ -253,7 +293,7 @@
                        }
                        if ( !$this->processRow(
                                $row, $count, $mode, $limit, $useInfo, $title, 
$fld_label, $fld_description,
-                               $fld_public, $fld_image, $fld_updated, 
$fld_owner, $path, $owner,
+                               $fld_public, $fld_review, $fld_image, 
$fld_updated, $fld_owner, $path, $owner,
                                $getContinueEnumParameter
                        ) ) {
                                break;
@@ -264,7 +304,7 @@
                        // There are no records in the database, and we need to 
inject watchlist row
                        $this->processRow(
                                null, $count, $mode, $limit, $useInfo, $title, 
$fld_label, $fld_description,
-                               $fld_public, $fld_image, $fld_updated, 
$fld_owner, $path, $owner,
+                               $fld_public, $fld_review, $fld_image, 
$fld_updated, $fld_owner, $path, $owner,
                                $getContinueEnumParameter
                        );
                }
@@ -297,6 +337,7 @@
                                        'label',
                                        'description',
                                        'public',
+                                       'review',
                                        'image',
                                        'count',
                                        'updated',
@@ -326,7 +367,7 @@
                                ApiBase::PARAM_TYPE => 'limit',
                                ApiBase::PARAM_MIN => 1,
                                ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
-                               ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+                               ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2,
                        ),
                        'continue' => array(
                                ApiBase::PARAM_HELP_MSG => 
'api-help-param-continue',
@@ -356,6 +397,7 @@
         * @param bool $fld_label True if the label property is requested.
         * @param bool $fld_description True if the description property is 
requested.
         * @param bool $fld_public True if the public property is requested.
+        * @param bool $fld_review True if the review property is requested.
         * @param bool $fld_image True if the image property is requested.
         * @param bool $fld_updated True if the updated property is requested.
         * @param bool $fld_owner True if the owner property is requested.
@@ -367,14 +409,19 @@
         */
        private function processRow(
                $row, &$count, $mode, $limit,  $useInfo, $title, $fld_label, 
$fld_description, $fld_public,
-               $fld_image, $fld_updated, $fld_owner, $path, $owner, 
$getContinueEnumParameter
+               $fld_review, $fld_image, $fld_updated, $fld_owner, $path, 
$owner, $getContinueEnumParameter
        ) {
+               global $wgGatherAutohideFlagLimit;
+
                if ( $row === null ) {
                        // Fake watchlist row
                        $row = (object) array(
                                'gl_id' => 0,
                                'gl_label' => '',
                                'gl_perm' => ApiEditList::PERM_PRIVATE,
+                               'gl_perm_override' => 
ApiEditList::PERM_OVERRIDE_NONE,
+                               'gl_needs_review' => 0,
+                               'gl_flag_count' => 0,
                                'gl_updated' => '',
                                'gl_info' => '',
                        );
@@ -412,19 +459,34 @@
                        }
                }
                if ( $fld_public ) {
-                       switch ( $row->gl_perm ) {
-                               case ApiEditList::PERM_PRIVATE:
-                                       $data['perm'] = 'private';
-                                       break;
-                               case ApiEditList::PERM_PUBLIC:
-                                       $data['perm'] = 'public';
-                                       break;
-                               case ApiEditList::PERM_HIDDEN:
-                                       $data['perm'] = 'hidden';
-                                       break;
-                               default:
-                                       $this->dieDebug( __METHOD__,
-                                               "Unknown gather 
perm={$row->gl_perm} for id {$row->gl_id}" );
+                       $permMap = array_flip( ApiEditList::$permMap );
+                       $permOverrideMap = self::$permOverrideApiMap;
+                       if ( !array_key_exists( $row->gl_perm, $permMap ) ) {
+                               $this->dieDebug( __METHOD__,
+                                       "Unknown gather perm={$row->gl_perm} 
for id {$row->gl_id}" );
+                       }
+                       if ( !array_key_exists( $row->gl_perm_override, 
$permOverrideMap ) ) {
+                               $this->dieDebug( __METHOD__, "Unknown gather "
+                                       . 
"perm_override={$row->gl_perm_override} for id {$row->gl_id}" );
+                       }
+                       $data['perm'] = $permMap[$row->gl_perm];
+                       if ( $permOverrideMap[$row->gl_perm_override] ) {
+                               $data['perm_override'] = 
$permOverrideMap[$row->gl_perm_override];
+                       }
+                       if ( $row->gl_flag_count >= $wgGatherAutohideFlagLimit 
) {
+                               $data['flagged'] = true;
+                       }
+                       if (
+                               $row->gl_perm_override === 
ApiEditList::PERM_OVERRIDE_HIDDEN
+                               || $row->gl_perm_override === 
ApiEditList::PERM_OVERRIDE_NONE
+                                       && $row->gl_flag_count >= 
$wgGatherAutohideFlagLimit
+                       ) {
+                               $data['hidden'] = true;
+                       }
+               }
+               if ( $fld_review ) {
+                       if ( $row->gl_needs_review || $row->gl_flag_count >= 
$wgGatherAutohideFlagLimit ) {
+                               $data['review'] = true;
                        }
                }
                if ( $useInfo ) {
diff --git a/includes/models/CollectionsList.php 
b/includes/models/CollectionsList.php
index 4346242..e4c61ca 100644
--- a/includes/models/CollectionsList.php
+++ b/includes/models/CollectionsList.php
@@ -145,12 +145,13 @@
         * @param boolean [$includePrivate] if the list should show private 
collections or not
         * @param string|boolean [$memberTitle] title of member to check for
         * @param array [$params] generate collection list with additional api 
parameters
-        * @param string [$mode] to run
+        * @param string [$mode] listing mode (public/private/review)
         * @param integer [$limit] of number of collections to show
         * @return models\CollectionsList List of collections.
         */
        public static function newFromApi( $user = null, $includePrivate = 
false,
-               $memberTitle = false, $params = array(), $mode = null, $limit = 
50 ) {
+               $memberTitle = false, $params = array(), $mode = null, $limit = 
50
+       ) {
                $collectionsList = new CollectionsList( $user, $includePrivate 
);
                $query = array_merge( $params, array(
                        'action' => 'query',
@@ -166,7 +167,7 @@
                        $query['lstowner'] = $user->getName();
                }
                if ( $mode ) {
-                       $query['lstmode'] = "all$mode";
+                       $query['lstmode'] = ( $mode === 'review' ) ? $mode : 
"all$mode";
                } else {
                        $mode = '';
                }
diff --git a/includes/specials/SpecialGather.php 
b/includes/specials/SpecialGather.php
index 7882586..73e3765 100644
--- a/includes/specials/SpecialGather.php
+++ b/includes/specials/SpecialGather.php
@@ -156,19 +156,23 @@
                                SpecialPage::getTitleFor( 'Gather' 
)->getSubPage( 'id' )->getSubPage( $id )->getLocalURL()
                        );
                } elseif ( preg_match( '/^all(\/(?<cond>[^\/]+))?\/?$/', 
$subpage, $matches ) ) {
-                       // All collections. Public or hidden
+                       // All collections. Public or hidden or pending review
                        // /all = /all/ = /all/public = /all/public/
                        // /all/hidden = /all/hidden/
                        // /all/recent = /all/recent/
+                       // /all/review = /all/review/
 
                        $apiParams = array();
                        $displayAsTable = true;
 
                        // Mode defaults to public
+                       $allowedModes = array( 'public', 'hidden', 'recent', 
'review' );
                        if ( !isset( $matches['cond'] ) ) {
                                $mode = 'public';
-                       } else {
+                       } elseif ( in_array( $matches['cond'], $allowedModes ) 
) {
                                $mode = $matches['cond'];
+                       } else {
+                               $mode = 'error';
                        }
                        $originalMode = $mode;
 
@@ -182,7 +186,7 @@
                                // Active is a concrete view of public with 
different params.
                                $mode = 'public';
 
-                       } elseif ( $mode === 'hidden' ) {
+                       } elseif ( $mode === 'hidden' || $mode === 'review' ) {
                                // Table list of hidden collections.
 
                                // If user doesn't have permissions, bail out.
@@ -207,7 +211,7 @@
 
                        $req = $this->getRequest();
                        $apiParams = array_merge( $apiParams, $req->getValues() 
);
-                       $cList = models\CollectionsList::newFromApi( null, 
$mode === 'hidden',
+                       $cList = models\CollectionsList::newFromApi( null, 
$mode,
                                false, $apiParams, $mode, 100 );
                        $cList->setMode( $originalMode );
                        if ( $displayAsTable ) {
diff --git a/schema/Updater.hooks.php b/schema/Updater.hooks.php
index d33e12f..610ac79 100644
--- a/schema/Updater.hooks.php
+++ b/schema/Updater.hooks.php
@@ -18,6 +18,7 @@
 
                $du->addExtensionTable( 'gather_list', "$dir/gather_list.sql" );
                $du->addExtensionTable( 'gather_list_item', 
"$dir/gather_list_item.sql" );
+               $du->addExtensionTable( 'gather_list_flag', 
"$dir/gather_list_flag.sql" );
 
                return true;
        }
@@ -33,6 +34,10 @@
                $du->addExtensionIndex( 'gather_list_item', 'gli_id_ns_title',
                        __DIR__ . '/archive/add-gli_id_ns_title.sql' );
 
+               // adds gl_flag_count, gl_perm_override and gl_needs_review at 
the same time
+               $du->addExtensionField( 'gather_list', 'gl_flag_count',
+                       __DIR__ . '/archive/add-gl_list-flag-columns.sql' );
+
                return true;
        }
 }
diff --git a/schema/archive/add-gl_list-flag-columns.sql 
b/schema/archive/add-gl_list-flag-columns.sql
new file mode 100644
index 0000000..c053f00
--- /dev/null
+++ b/schema/archive/add-gl_list-flag-columns.sql
@@ -0,0 +1,12 @@
+ALTER TABLE /*_*/gather_list
+  ADD gl_perm_override TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER gl_perm,
+  ADD gl_needs_review TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER 
gl_perm_override,
+  ADD gl_flag_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER gl_needs_review;
+
+-- migrate PERM_HIDDEN to PERM_PUBLIC + PERM_OVERRIDE_HIDDEN
+UPDATE /*_*/gather_list
+  SET gl_perm = 1, gl_perm_override = 1
+  WHERE gl_perm = 2;
+
+DROP INDEX /*i*/gl_perm_updated ON /*_*/gather_list;
+CREATE INDEX /*i*/gl_visibility_updated ON /*_*/gather_list (gl_perm, 
gl_perm_override, gl_flag_count, gl_needs_review, gl_updated, gl_id);
diff --git a/schema/gather_list.sql b/schema/gather_list.sql
index 7d4bc5c..68df082 100644
--- a/schema/gather_list.sql
+++ b/schema/gather_list.sql
@@ -16,11 +16,21 @@
   -- Must be unique per user - makes it easier for querying/looking up
   gl_label VARCHAR(255) BINARY NOT NULL,
 
-  -- The list permissions type (PRIVATE=0, PUBLIC=1, HIDDEN=2, ...)
+  -- The list permissions type (PRIVATE=0, PUBLIC=1)
   gl_perm TINYINT UNSIGNED NOT NULL,
+
+  -- The list permissions type (NONE=0, HIDDEN=1, APPROVED=2)
+  gl_perm_override TINYINT UNSIGNED NOT NULL DEFAULT 0,
 
   -- The number of items (pages) in this collection
   gl_item_count INT UNSIGNED NOT NULL DEFAULT 0,
+
+  -- The number of unreviewed flags (glf_reviewed=0) on this collection
+  gl_flag_count INT UNSIGNED NOT NULL DEFAULT 0,
+
+  -- True if the list is in a state which is the result of admin review, but 
changed since
+  -- the last review and needs to be reevaluated
+  gl_needs_review BOOL NOT NULL DEFAULT false,
 
   -- The timestamp is updated whenever the list's meta data is modified.
   -- It is possible we might update this field when modifying watchlist / list 
pages
@@ -38,5 +48,5 @@
 CREATE UNIQUE INDEX /*i*/gl_user_label ON /*_*/gather_list (gl_user, gl_label);
 
 -- Show all public lists, sorted by the last updated timestamp
--- gl_id is included to allow for safe continuation of the query
-CREATE INDEX /*i*/gl_perm_updated ON /*_*/gather_list (gl_perm, gl_updated);
+CREATE INDEX /*i*/gl_visibility_updated ON /*_*/gather_list (gl_perm, 
gl_perm_override, gl_flag_count, gl_needs_review, gl_updated, gl_id);
+
diff --git a/schema/gather_list_flag.sql b/schema/gather_list_flag.sql
new file mode 100644
index 0000000..ca83da5
--- /dev/null
+++ b/schema/gather_list_flag.sql
@@ -0,0 +1,20 @@
+-- Licence: CC0
+-- Run with maintenance/sql.php for automatic replacement of /*_*/ etc
+
+CREATE TABLE /*_*/gather_list_flag (
+  -- ID of the collection which has been flagged
+  glf_gl_id INT UNSIGNED NOT NULL,
+
+  -- Foreign key to user.user_id, 0 for anonymous
+  glf_user_id INT UNSIGNED NOT NULL DEFAULT 0,
+
+  -- IP address for anonymous users
+  glf_user_ip VARBINARY(40) NOT NULL DEFAULT '',
+
+  -- Marks flags which have been seen by an admin already and should not be 
used to autohide
+  glf_reviewed BOOL NOT NULL DEFAULT false
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/glf_list_user_ip ON /*_*/gather_list_flag (glf_gl_id, 
glf_user_id, glf_user_ip);
+CREATE INDEX /*i*/glf_list_reviewed ON /*_*/gather_list_flag (glf_gl_id, 
glf_reviewed);
+
diff --git a/tests/phpunit/api/ApiEditListTest.php 
b/tests/phpunit/api/ApiEditListTest.php
index e0d6856..02f3eff 100644
--- a/tests/phpunit/api/ApiEditListTest.php
+++ b/tests/phpunit/api/ApiEditListTest.php
@@ -30,6 +30,33 @@
                $this->assertItemCount( 5, $listId );
        }
 
+       public function testPermissionOverrideChangeOnEdit() {
+               $listId = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->setListPermissionOverride( $listId, 'approve' );
+               $this->doApiRequestWithWatchToken( array(
+                       'action' => 'editlist',
+                       'id' => $listId,
+                       'label' => 'new test label',
+               ), null, false, static::$users['gatherUser']->getUser() );
+
+               $listData = $this->getListData( $listId );
+               $this->assertArrayNotHasKey( 'perm_override', $listData );
+       }
+
+       public function testOneFlagPerUser() {
+               $this->addTestUsers( 'flagger' );
+               $user = static::$users['flagger']->getUser();
+               $listId = $this->createList( 'gatherUser', array( 'P1' ) );
+               $params = array(
+                       'action' => 'editlist',
+                       'mode' => 'flag',
+                       'id' => $listId,
+               );
+
+               $this->doApiRequestWithWatchToken( $params, null, false, $user 
);
+               $this->assertRequestFails( $params, $user, true );
+       }
+
        /**
         * Verifies (via direct DB access) gather_list.gl_item_count
         * @param int $expected
diff --git a/tests/phpunit/api/ApiQueryListPagesTest.php 
b/tests/phpunit/api/ApiQueryListPagesTest.php
index 2023cd4..12d04e9 100644
--- a/tests/phpunit/api/ApiQueryListPagesTest.php
+++ b/tests/phpunit/api/ApiQueryListPagesTest.php
@@ -10,6 +10,11 @@
 class ApiQueryListPagesTest extends GatherTestCase {
        protected $tablesUsed = array( 'gather_list', 'gather_list_item' );
 
+       public function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( 'wgGatherAutohideFlagLimit', 3 );
+       }
+
        public function testSort() {
                $listId = $this->createList( 'gatherUser', array( 'P1', 'P2', 
'Talk:P3', 'Help:P4', 'P5' ) );
 
@@ -36,6 +41,29 @@
                $this->assertArrayEquals( array( 'P1', 'P2', 'P5', 'Talk:P3', 
'Help:P4' ), $pages, true );
        }
 
+       // also tests access control for the listmembership API as both use 
ApiMixinListAccess
+       public function testAccessControl() {
+               $listId = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->addTestUsers( 'flagger1', 'flagger2', 'flagger3' );
+               $request = array(
+                       'action' => 'query',
+                       'list' => 'listpages',
+                       'lspid' => $listId,
+               );
+
+               $pages = $this->getPagenamesFromResults( $this->doApiRequest( 
$request ) );
+               $this->assertArrayEquals( array( 'P1' ), $pages );
+
+               $this->setListPermissionOverride( $listId, 'hidelist' );
+               $this->assertRequestFails( $request );
+
+               $this->setListPermissionOverride( $listId, 'showlist' );
+               $this->flagList( 'flagger1', $listId );
+               $this->flagList( 'flagger2', $listId );
+               $this->flagList( 'flagger3', $listId );
+               $this->assertRequestFails( $request );
+       }
+
        /**
         * Returns page names (without namespace) from a listpages API query 
result
         * @param array $ret Return value of doApiRequest()
diff --git a/tests/phpunit/api/ApiQueryListsTest.php 
b/tests/phpunit/api/ApiQueryListsTest.php
index 74b075f..2b81190 100644
--- a/tests/phpunit/api/ApiQueryListsTest.php
+++ b/tests/phpunit/api/ApiQueryListsTest.php
@@ -10,6 +10,11 @@
 class ApiQueryListsTest extends GatherTestCase {
        protected $tablesUsed = array( 'gather_list', 'gather_list_item' );
 
+       public function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( 'wgGatherAutohideFlagLimit', 3 );
+       }
+
        public function testMinitems() {
                $list1 = $this->createList( 'gatherUser', array( 'P1', 'P2' ) );
                $list2 = $this->createList( 'gatherUser', array( 'P1', 'P2', 
'P3' ) );
@@ -24,6 +29,67 @@
                $this->assertArrayEquals( array( $list2, $list3 ), $minitems3 );
        }
 
+       public function testVisibility() {
+               $this->addTestUsers( 'flagger1', 'flagger2', 'flagger3' );
+
+               // normal list
+               $list1 = $this->createList( 'gatherUser', array( 'P1' ) );
+
+               // hidden list
+               $list2 = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->setListPermissionOverride( $list2, 'hidelist' );
+
+               // normal list with 3 flags
+               $list3 = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->flagList( 'flagger1', $list3 );
+               $this->flagList( 'flagger2', $list3 );
+               $this->flagList( 'flagger3', $list3 );
+
+               // approved list with 3 flags
+               $list4 = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->setListPermissionOverride( $list4, 'approve' );
+               $this->flagList( 'flagger1', $list4 );
+               $this->flagList( 'flagger2', $list4 );
+               $this->flagList( 'flagger3', $list4 );
+
+               // normal list with 3 flags which have been reviewed
+               $list5 = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->flagList( 'flagger1', $list5 );
+               $this->flagList( 'flagger2', $list5 );
+               $this->flagList( 'flagger3', $list5 );
+               $this->setListPermissionOverride( $list5, 'showlist' );
+
+               // hidden list which needs review
+               $list6 = $this->createList( 'gatherUser', array( 'P1' ) );
+               $this->setListPermissionOverride( $list6, 'hidelist' );
+               $this->doApiRequestWithWatchToken( array(
+                       'action' => 'editlist',
+                       'id' => $list6,
+                       'label' => 'new test label',
+               ), null, false, static::$users['gatherUser']->getUser() );
+
+               $normalList = $this->getListIdsFromResults( 
$this->doApiRequest( array(
+                       'action' => 'query',
+                       'list' => 'lists',
+                       'lstmode' => 'allpublic',
+               ) ) );
+               $this->assertArrayEquals( array( $list1, $list4, $list5 ), 
$normalList );
+
+               $hiddenlList = $this->getListIdsFromResults( 
$this->doApiRequest( array(
+                       'action' => 'query',
+                       'list' => 'lists',
+                       'lstmode' => 'allhidden',
+               ), null, false, static::$users['sysop']->getUser() ) );
+               $this->assertArrayEquals( array( $list2, $list3, $list6 ), 
$hiddenlList );
+
+               $reviewList = $this->getListIdsFromResults( 
$this->doApiRequest( array(
+                       'action' => 'query',
+                       'list' => 'lists',
+                       'lstmode' => 'review',
+               ), null, false, static::$users['sysop']->getUser() ) );
+               $this->assertArrayEquals( array( $list3, $list4, $list6 ), 
$reviewList );
+       }
+
        /**
         * Returns Gather list IDs from a lists API query result
         * @param array $ret Return value of doApiRequest()
diff --git a/tests/phpunit/api/GatherTest.php b/tests/phpunit/api/GatherTest.php
index 989e529..1d211ea 100644
--- a/tests/phpunit/api/GatherTest.php
+++ b/tests/phpunit/api/GatherTest.php
@@ -548,7 +548,8 @@
                $res = $this->editList( 'ed-o2', $usrS, $tokenS, $idBs . ', 
"mode":"hidelist"' );
                $this->getVal( 'ed-o2', '"status"', $res, 'updated' );
                $this->getVal( 'ed-o2', '"id"', $res, $idB );
-               $expListsB2->perm = 'hidden';
+               $expListsB2->perm_override = 'hidden';
+               $expListsB2->hidden = true;
 
                $this->assertPages( 'ed-o3', $usr, $idB, $expPagesB );
                $this->assertLists( 'ed-o4', $usr, $idB, $expListsB, 
$expListsB2 );
@@ -586,7 +587,8 @@
                $res = $this->editList( 'ed-p1', $usrS, $tokenS, $idBs . ', 
"mode":"showlist"' );
                $this->getVal( 'ed-p1', '"status"', $res, 'updated' );
                $this->getVal( 'ed-p1', '"id"', $res, $idB );
-               $expListsB2->perm = 'public';
+               unset( $expListsB2->perm_override );
+               unset( $expListsB2->hidden );
 
                $this->assertPages( 'ed-p2', $usr, $idB, $expPagesB );
                $this->assertLists( 'ed-p3', $usr, $idB, $expListsB, 
$expListsB2 );
diff --git a/tests/phpunit/api/GatherTestCase.php 
b/tests/phpunit/api/GatherTestCase.php
index 61d7165..56fa3fe 100644
--- a/tests/phpunit/api/GatherTestCase.php
+++ b/tests/phpunit/api/GatherTestCase.php
@@ -6,10 +6,26 @@
 
        public function setUp() {
                parent::setUp();
-               if ( !array_key_exists( 'gatherUser', self::$gatherUsers ) ) {
-                       self::$gatherUsers['gatherUser'] = new TestUser( 
'gatherUser' );
-               }
                static::$users = array_merge( static::$users, 
self::$gatherUsers );
+               $this->addTestUsers( 'gatherUser', 'loggedInUser' );
+       }
+
+       /**
+        * @param string $name Username of the test user. Additional usernames 
can be passed as
+        *   additional arguments.
+        */
+       protected function addTestUsers( $name /* ... */ ) {
+               // This is a bit overcomplicated but I ran into trouble 
whenever I tried to implement it
+               // in saner ways. $users is reset in setUp() but the user DB 
does not so simply extending
+               // $users would result in a duplicate key error.
+               foreach ( func_get_args() as $name ) {
+                       if ( !array_key_exists( $name, self::$gatherUsers ) ) {
+                               if ( array_key_exists( $name, static::$users ) 
) {
+                                       throw new Exception( 'reserved 
username: ' . $name );
+                               }
+                               static::$users[$name] = 
self::$gatherUsers[$name] = new TestUser( $name );
+                       }
+               }
        }
 
        protected function doApiRequestWithWatchToken(
@@ -47,12 +63,47 @@
                $params = array_merge( array(
                        'action' => 'editlist',
                        'label' => $label,
+                       'perm' => 'public',
                        'titles' => implode( '|', $pages ),
                ), $properties );
                $result = $this->getFromResults( 
$this->doApiRequestWithWatchToken( $params, null, false,
                        $user ), 'editlist' );
                $this->assertEquals( 'created', $result['status'] );
                return $result['id'];
+       }
+
+       /**
+        * @param User|string $user Flagging user (as User object or $users 
index)
+        * @param int $listId
+        */
+       protected function flagList( $user, $listId ) {
+               if ( is_string( $user ) ) {
+                       $user = static::$users[$user]->getUser();
+               }
+               $params = array(
+                       'action' => 'editlist',
+                       'mode' => 'flag',
+                       'id' => $listId,
+               );
+               $result = $this->getFromResults( 
$this->doApiRequestWithWatchToken( $params, null, false,
+                       $user ), 'editlist' );
+               $this->assertEquals( 'flagged', $result['status'] );
+       }
+
+       /**
+        * @param int $listId
+        * @param string $mode editlist API mode parameter
+        */
+       protected function setListPermissionOverride( $listId, $mode ) {
+               $user = static::$users['sysop']->getUser();
+               $params = array(
+                       'action' => 'editlist',
+                       'mode' => $mode,
+                       'id' => $listId,
+               );
+               $result = $this->getFromResults( 
$this->doApiRequestWithWatchToken( $params, null, false,
+                       $user ), 'editlist' );
+               $this->assertContains( $result['status'], array( 'updated', 
'unchanged' ) );
        }
 
        /**
@@ -76,8 +127,42 @@
                }
        }
 
+       protected function getListData( $listId, $user = null ) {
+               $lists = $this->getFromResults( $this->doApiRequest( array(
+                       'action' => 'query',
+                       'list' => 'lists',
+                       'lstid' => $listId,
+                       'lstprop' => 
'label|description|public|review|image|count|updated|owner',
+               ), null, false, $user ), 'lists' );
+               $this->assertNotEmpty( $lists, "List $listId not found" );
+               return $lists[0];
+       }
+
        protected function debug( $enable = true ) {
                global $wgDebugLogFile;
                $wgDebugLogFile = $enable ? 
'/vagrant/logs/mediawiki-phpunit-debug.log' : null;
        }
+
+       /**
+        * Asserts that an API request failed (e.g. due to lack of permissions)
+        * @param array $params Request parameters
+        * @param User $user
+        * @param bool $withWatchToken Set to true if the API request requires 
a watch token.
+        */
+       protected function assertRequestFails( $params, $user = null, 
$withWatchToken = false ) {
+               try {
+                       if ( $withWatchToken ) {
+                               $this->doApiRequestWithWatchToken( $params, 
null, false, $user );
+                       } else {
+                               $this->doApiRequest( $params, null, false, 
$user );
+                       }
+               } catch ( UsageException $e ) {
+                       $this->assertTrue( true );
+                       return;
+               }
+               $this->fail( 'Failed to assert that API request fails: '
+                       . 'user: ' . ( $user ? $user->getName() : 'anon' ) . ', 
'
+                       . 'params: ' . json_encode( $params )
+               );
+       }
 }

-- 
To view, visit https://gerrit.wikimedia.org/r/212593
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Iae85317f9560d80475b53a66691b474981c13668
Gerrit-PatchSet: 40
Gerrit-Project: mediawiki/extensions/Gather
Gerrit-Branch: master
Gerrit-Owner: Gergő Tisza <[email protected]>
Gerrit-Reviewer: Anomie <[email protected]>
Gerrit-Reviewer: BarryTheBrowserTestBot <[email protected]>
Gerrit-Reviewer: Gergő Tisza <[email protected]>
Gerrit-Reviewer: Jdlrobson <[email protected]>
Gerrit-Reviewer: MaxSem <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: Springle <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to