| Total Complexity | 111 | 
| Total Lines | 944 | 
| Duplicated Lines | 0 % | 
| Changes | 0 | ||
Complex classes like XtoolsController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use XtoolsController, and based on these observations, apply Extract Interface, too.
| 1 | <?php  | 
            ||
| 35 | abstract class XtoolsController extends Controller  | 
            ||
| 
                                                                                                    
                        
                         | 
                |||
| 36 | { | 
            ||
| 37 | /** @var I18nHelper i18n helper. */  | 
            ||
| 38 | protected $i18n;  | 
            ||
| 39 | |||
| 40 | /** @var Request The request object. */  | 
            ||
| 41 | protected $request;  | 
            ||
| 42 | |||
| 43 | /** @var string Name of the action within the child controller that is being executed. */  | 
            ||
| 44 | protected $controllerAction;  | 
            ||
| 45 | |||
| 46 | /** @var array Hash of params parsed from the Request. */  | 
            ||
| 47 | protected $params;  | 
            ||
| 48 | |||
| 49 | /** @var bool Whether this is a request to an API action. */  | 
            ||
| 50 | protected $isApi;  | 
            ||
| 51 | |||
| 52 | /** @var Project Relevant Project parsed from the Request. */  | 
            ||
| 53 | protected $project;  | 
            ||
| 54 | |||
| 55 | /** @var User Relevant User parsed from the Request. */  | 
            ||
| 56 | protected $user;  | 
            ||
| 57 | |||
| 58 | /** @var Page Relevant Page parsed from the Request. */  | 
            ||
| 59 | protected $page;  | 
            ||
| 60 | |||
| 61 | /** @var int|false Start date parsed from the Request. */  | 
            ||
| 62 | protected $start = false;  | 
            ||
| 63 | |||
| 64 | /** @var int|false End date parsed from the Request. */  | 
            ||
| 65 | protected $end = false;  | 
            ||
| 66 | |||
| 67 | /**  | 
            ||
| 68 | * Default days from current day, to use as the start date if none was provided.  | 
            ||
| 69 | * If this is null and $maxDays is non-null, the latter will be used as the default.  | 
            ||
| 70 | * Is public visibility evil here? I don't think so.  | 
            ||
| 71 | * @var int|null  | 
            ||
| 72 | */  | 
            ||
| 73 | public $defaultDays = null;  | 
            ||
| 74 | |||
| 75 | /**  | 
            ||
| 76 | * Maximum number of days allowed for the given date range.  | 
            ||
| 77 | * Set this in the controller's constructor to enforce the given date range to be within this range.  | 
            ||
| 78 | * This will be used as the default date span unless $defaultDays is defined.  | 
            ||
| 79 | * @see XtoolsController::getUnixFromDateParams()  | 
            ||
| 80 | * @var int|null  | 
            ||
| 81 | */  | 
            ||
| 82 | public $maxDays = null;  | 
            ||
| 83 | |||
| 84 | /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */  | 
            ||
| 85 | protected $namespace;  | 
            ||
| 86 | |||
| 87 | /** @var int|false Unix timestamp. Pagination offset that substitutes for $end. */  | 
            ||
| 88 | protected $offset = false;  | 
            ||
| 89 | |||
| 90 | /** @var int Number of results to return. */  | 
            ||
| 91 | protected $limit;  | 
            ||
| 92 | |||
| 93 | /**  | 
            ||
| 94 | * Maximum number of results to show per page. Can be overridden in the child controller's constructor.  | 
            ||
| 95 | * @var int  | 
            ||
| 96 | */  | 
            ||
| 97 | public $maxLimit = 5000;  | 
            ||
| 98 | |||
| 99 | /** @var bool Is the current request a subrequest? */  | 
            ||
| 100 | protected $isSubRequest;  | 
            ||
| 101 | |||
| 102 | /**  | 
            ||
| 103 | * Stores user preferences such default project.  | 
            ||
| 104 | * This may get altered from the Request and updated in the Response.  | 
            ||
| 105 | * @var array  | 
            ||
| 106 | */  | 
            ||
| 107 | protected $cookies = [  | 
            ||
| 108 | 'XtoolsProject' => null,  | 
            ||
| 109 | ];  | 
            ||
| 110 | |||
| 111 | /**  | 
            ||
| 112 | * This activates the 'too high edit count' functionality. This property represents the  | 
            ||
| 113 | * action that should be redirected to if the user has too high of an edit count.  | 
            ||
| 114 | * @var string  | 
            ||
| 115 | */  | 
            ||
| 116 | protected $tooHighEditCountAction;  | 
            ||
| 117 | |||
| 118 | /** @var array Actions that are exempt from edit count limitations. */  | 
            ||
| 119 | protected $tooHighEditCountActionBlacklist = [];  | 
            ||
| 120 | |||
| 121 | /**  | 
            ||
| 122 | * Actions that require the target user to opt in to the restricted statistics.  | 
            ||
| 123 | * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats  | 
            ||
| 124 | * @var string[]  | 
            ||
| 125 | */  | 
            ||
| 126 | protected $restrictedActions = [];  | 
            ||
| 127 | |||
| 128 | /**  | 
            ||
| 129 | * XtoolsController::validateProject() will ensure the given project matches one of these domains,  | 
            ||
| 130 | * instead of any valid project.  | 
            ||
| 131 | * @var string[]  | 
            ||
| 132 | */  | 
            ||
| 133 | protected $supportedProjects;  | 
            ||
| 134 | |||
| 135 | /**  | 
            ||
| 136 | * Require the tool's index route (initial form) be defined here. This should also  | 
            ||
| 137 | * be the name of the associated model, if present.  | 
            ||
| 138 | * @return string  | 
            ||
| 139 | */  | 
            ||
| 140 | abstract protected function getIndexRoute(): string;  | 
            ||
| 141 | |||
| 142 | /**  | 
            ||
| 143 | * XtoolsController constructor.  | 
            ||
| 144 | * @param RequestStack $requestStack  | 
            ||
| 145 | * @param ContainerInterface $container  | 
            ||
| 146 | * @param I18nHelper $i18n  | 
            ||
| 147 | */  | 
            ||
| 148 | public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)  | 
            ||
| 149 |     { | 
            ||
| 150 | $this->request = $requestStack->getCurrentRequest();  | 
            ||
| 151 | $this->container = $container;  | 
            ||
| 152 | $this->i18n = $i18n;  | 
            ||
| 153 | $this->params = $this->parseQueryParams();  | 
            ||
| 154 | |||
| 155 | // Parse out the name of the controller and action.  | 
            ||
| 156 | $pattern = "#::([a-zA-Z]*)Action#";  | 
            ||
| 157 | $matches = [];  | 
            ||
| 158 | // The blank string here only happens in the unit tests, where the request may not be made to an action.  | 
            ||
| 159 |         preg_match($pattern, $this->request->get('_controller') ?? '', $matches); | 
            ||
| 160 | $this->controllerAction = $matches[1] ?? '';  | 
            ||
| 161 | |||
| 162 | // Whether the action is an API action.  | 
            ||
| 163 | $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;  | 
            ||
| 164 | |||
| 165 | // Whether we're making a subrequest (the view makes a request to another action).  | 
            ||
| 166 |         $this->isSubRequest = $this->request->get('htmlonly') | 
            ||
| 167 |             || null !== $this->get('request_stack')->getParentRequest(); | 
            ||
| 168 | |||
| 169 | // Disallow AJAX (unless it's an API or subrequest).  | 
            ||
| 170 | $this->checkIfAjax();  | 
            ||
| 171 | |||
| 172 | // Load user options from cookies.  | 
            ||
| 173 | $this->loadCookies();  | 
            ||
| 174 | |||
| 175 | // Set the class-level properties based on params.  | 
            ||
| 176 |         if (false !== strpos(strtolower($this->controllerAction), 'index')) { | 
            ||
| 177 | // Index pages should only set the project, and no other class properties.  | 
            ||
| 178 | $this->setProject($this->getProjectFromQuery());  | 
            ||
| 179 | |||
| 180 | // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to  | 
            ||
| 181 | // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as  | 
            ||
| 182 | // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'.  | 
            ||
| 183 | // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange.  | 
            ||
| 184 |             if (isset($this->params['username']) && IPUtils::isValidRange($this->params['username'])) { | 
            ||
| 185 | $this->params['username'] = 'ipr-'.$this->params['username'];  | 
            ||
| 186 | }  | 
            ||
| 187 |         } else { | 
            ||
| 188 | $this->setProperties(); // Includes the project.  | 
            ||
| 189 | }  | 
            ||
| 190 | |||
| 191 | // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.  | 
            ||
| 192 | $this->checkRestrictedApiEndpoint();  | 
            ||
| 193 | }  | 
            ||
| 194 | |||
| 195 | /**  | 
            ||
| 196 | * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.  | 
            ||
| 197 | */  | 
            ||
| 198 | private function checkIfAjax(): void  | 
            ||
| 199 |     { | 
            ||
| 200 |         if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) { | 
            ||
| 201 | throw new HttpException(  | 
            ||
| 202 | 403,  | 
            ||
| 203 |                 $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API']) | 
            ||
| 204 | );  | 
            ||
| 205 | }  | 
            ||
| 206 | }  | 
            ||
| 207 | |||
| 208 | /**  | 
            ||
| 209 | * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.  | 
            ||
| 210 | * @throws XtoolsHttpException  | 
            ||
| 211 | */  | 
            ||
| 212 | private function checkRestrictedApiEndpoint(): void  | 
            ||
| 213 |     { | 
            ||
| 214 | $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);  | 
            ||
| 215 | |||
| 216 |         if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) { | 
            ||
| 217 | throw new XtoolsHttpException(  | 
            ||
| 218 |                 $this->i18n->msg('not-opted-in', [ | 
            ||
| 219 | $this->getOptedInPage()->getTitle(),  | 
            ||
| 220 |                     $this->i18n->msg('not-opted-in-link') . | 
            ||
| 221 | ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',  | 
            ||
| 222 |                     $this->i18n->msg('not-opted-in-login'), | 
            ||
| 223 | ]),  | 
            ||
| 224 | '',  | 
            ||
| 225 | $this->params,  | 
            ||
| 226 | true,  | 
            ||
| 227 | Response::HTTP_UNAUTHORIZED  | 
            ||
| 228 | );  | 
            ||
| 229 | }  | 
            ||
| 230 | }  | 
            ||
| 231 | |||
| 232 | /**  | 
            ||
| 233 | * Get the path to the opt-in page for restricted statistics.  | 
            ||
| 234 | * @return Page  | 
            ||
| 235 | */  | 
            ||
| 236 | protected function getOptedInPage(): Page  | 
            ||
| 237 |     { | 
            ||
| 238 | return $this->project  | 
            ||
| 239 | ->getRepository()  | 
            ||
| 240 | ->getPage($this->project, $this->project->userOptInPage($this->user));  | 
            ||
| 241 | }  | 
            ||
| 242 | |||
| 243 | /***********  | 
            ||
| 244 | * COOKIES *  | 
            ||
| 245 | ***********/  | 
            ||
| 246 | |||
| 247 | /**  | 
            ||
| 248 | * Load user preferences from the associated cookies.  | 
            ||
| 249 | */  | 
            ||
| 250 | private function loadCookies(): void  | 
            ||
| 251 |     { | 
            ||
| 252 | // Not done for subrequests.  | 
            ||
| 253 |         if ($this->isSubRequest) { | 
            ||
| 254 | return;  | 
            ||
| 255 | }  | 
            ||
| 256 | |||
| 257 |         foreach (array_keys($this->cookies) as $name) { | 
            ||
| 258 | $this->cookies[$name] = $this->request->cookies->get($name);  | 
            ||
| 259 | }  | 
            ||
| 260 | }  | 
            ||
| 261 | |||
| 262 | /**  | 
            ||
| 263 | * Set cookies on the given Response.  | 
            ||
| 264 | * @param Response $response  | 
            ||
| 265 | */  | 
            ||
| 266 | private function setCookies(Response &$response): void  | 
            ||
| 267 |     { | 
            ||
| 268 | // Not done for subrequests.  | 
            ||
| 269 |         if ($this->isSubRequest) { | 
            ||
| 270 | return;  | 
            ||
| 271 | }  | 
            ||
| 272 | |||
| 273 |         foreach ($this->cookies as $name => $value) { | 
            ||
| 274 | $response->headers->setCookie(  | 
            ||
| 275 | Cookie::create($name, $value)  | 
            ||
| 276 | );  | 
            ||
| 277 | }  | 
            ||
| 278 | }  | 
            ||
| 279 | |||
| 280 | /**  | 
            ||
| 281 | * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will  | 
            ||
| 282 | * later get set on the Response headers in self::getFormattedResponse().  | 
            ||
| 283 | * @param Project $project  | 
            ||
| 284 | */  | 
            ||
| 285 | private function setProject(Project $project): void  | 
            ||
| 286 |     { | 
            ||
| 287 | // TODO: Remove after deprecated routes are retired.  | 
            ||
| 288 |         if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) { | 
            ||
| 289 | return;  | 
            ||
| 290 | }  | 
            ||
| 291 | |||
| 292 | $this->project = $project;  | 
            ||
| 293 | $this->cookies['XtoolsProject'] = $project->getDomain();  | 
            ||
| 294 | }  | 
            ||
| 295 | |||
| 296 | /****************************  | 
            ||
| 297 | * SETTING CLASS PROPERTIES *  | 
            ||
| 298 | ****************************/  | 
            ||
| 299 | |||
| 300 | /**  | 
            ||
| 301 | * Normalize all common parameters used by the controllers and set class properties.  | 
            ||
| 302 | */  | 
            ||
| 303 | private function setProperties(): void  | 
            ||
| 304 |     { | 
            ||
| 305 | $this->namespace = $this->params['namespace'] ?? null;  | 
            ||
| 306 | |||
| 307 | // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).  | 
            ||
| 308 |         if (isset($this->params['offset'])) { | 
            ||
| 309 | $this->offset = strtotime($this->params['offset']);  | 
            ||
| 310 | }  | 
            ||
| 311 | |||
| 312 | // Limit needs to be an int.  | 
            ||
| 313 |         if (isset($this->params['limit'])) { | 
            ||
| 314 | // Normalize.  | 
            ||
| 315 | $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit);  | 
            ||
| 316 | $this->limit = $this->params['limit'];  | 
            ||
| 317 | }  | 
            ||
| 318 | |||
| 319 |         if (isset($this->params['project'])) { | 
            ||
| 320 | $this->setProject($this->validateProject($this->params['project']));  | 
            ||
| 321 |         } elseif (null !== $this->cookies['XtoolsProject']) { | 
            ||
| 322 | // Set from cookie.  | 
            ||
| 323 | $this->setProject(  | 
            ||
| 324 | $this->validateProject($this->cookies['XtoolsProject'])  | 
            ||
| 325 | );  | 
            ||
| 326 | }  | 
            ||
| 327 | |||
| 328 |         if (isset($this->params['username'])) { | 
            ||
| 329 | $this->user = $this->validateUser($this->params['username']);  | 
            ||
| 330 | }  | 
            ||
| 331 |         if (isset($this->params['page'])) { | 
            ||
| 332 | $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);  | 
            ||
| 333 | }  | 
            ||
| 334 | |||
| 335 | $this->setDates();  | 
            ||
| 336 | }  | 
            ||
| 337 | |||
| 338 | /**  | 
            ||
| 339 | * Set class properties for dates, if such params were passed in.  | 
            ||
| 340 | */  | 
            ||
| 341 | private function setDates(): void  | 
            ||
| 342 |     { | 
            ||
| 343 | $start = $this->params['start'] ?? false;  | 
            ||
| 344 | $end = $this->params['end'] ?? false;  | 
            ||
| 345 |         if ($start || $end || null !== $this->maxDays) { | 
            ||
| 346 | [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);  | 
            ||
| 347 | |||
| 348 | // Set $this->params accordingly too, so that for instance API responses will include it.  | 
            ||
| 349 |             $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false; | 
            ||
| 350 |             $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false; | 
            ||
| 351 | }  | 
            ||
| 352 | }  | 
            ||
| 353 | |||
| 354 | /**  | 
            ||
| 355 | * Construct a fully qualified page title given the namespace and title.  | 
            ||
| 356 | * @param int|string $ns Namespace ID.  | 
            ||
| 357 | * @param string $title Page title.  | 
            ||
| 358 | * @param bool $rawTitle Return only the title (and not a Page).  | 
            ||
| 359 | * @return Page|string  | 
            ||
| 360 | */  | 
            ||
| 361 | protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)  | 
            ||
| 362 |     { | 
            ||
| 363 |         if (0 === (int)$ns) { | 
            ||
| 364 | return $rawTitle ? $title : $this->validatePage($title);  | 
            ||
| 365 | }  | 
            ||
| 366 | |||
| 367 | // Prepend namespace and strip out duplicates.  | 
            ||
| 368 |         $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown'); | 
            ||
| 369 |         $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title); | 
            ||
| 370 | return $rawTitle ? $title : $this->validatePage($title);  | 
            ||
| 371 | }  | 
            ||
| 372 | |||
| 373 | /**  | 
            ||
| 374 | * Get a Project instance from the project string, using defaults if the given project string is invalid.  | 
            ||
| 375 | * @return Project  | 
            ||
| 376 | */  | 
            ||
| 377 | public function getProjectFromQuery(): Project  | 
            ||
| 400 | }  | 
            ||
| 401 | |||
| 402 | /*************************  | 
            ||
| 403 | * GETTERS / VALIDATIONS *  | 
            ||
| 404 | *************************/  | 
            ||
| 405 | |||
| 406 | /**  | 
            ||
| 407 | * Validate the given project, returning a Project if it is valid or false otherwise.  | 
            ||
| 408 | * @param string $projectQuery Project domain or database name.  | 
            ||
| 409 | * @return Project  | 
            ||
| 410 | * @throws XtoolsHttpException  | 
            ||
| 411 | */  | 
            ||
| 412 | public function validateProject(string $projectQuery): Project  | 
            ||
| 413 |     { | 
            ||
| 414 | /** @var Project $project */  | 
            ||
| 415 | $project = ProjectRepository::getProject($projectQuery, $this->container);  | 
            ||
| 416 | |||
| 417 | // Check if it is an explicitly allowed project for the current tool.  | 
            ||
| 418 |         if (isset($this->supportedProjects) && !in_array($project->getDomain(), $this->supportedProjects)) { | 
            ||
| 419 | $this->throwXtoolsException(  | 
            ||
| 420 | $this->getIndexRoute(),  | 
            ||
| 421 | 'error-authorship-unsupported-project',  | 
            ||
| 422 | [$this->params['project']],  | 
            ||
| 423 | 'project'  | 
            ||
| 424 | );  | 
            ||
| 425 | }  | 
            ||
| 426 | |||
| 427 |         if (!$project->exists()) { | 
            ||
| 428 | $this->throwXtoolsException(  | 
            ||
| 429 | $this->getIndexRoute(),  | 
            ||
| 430 | 'invalid-project',  | 
            ||
| 431 | [$this->params['project']],  | 
            ||
| 432 | 'project'  | 
            ||
| 433 | );  | 
            ||
| 434 | }  | 
            ||
| 435 | |||
| 436 | return $project;  | 
            ||
| 437 | }  | 
            ||
| 438 | |||
| 439 | /**  | 
            ||
| 440 | * Validate the given user, returning a User or Redirect if they don't exist.  | 
            ||
| 441 | * @param string $username  | 
            ||
| 442 | * @return User  | 
            ||
| 443 | * @throws XtoolsHttpException  | 
            ||
| 444 | */  | 
            ||
| 445 | public function validateUser(string $username): User  | 
            ||
| 446 |     { | 
            ||
| 447 | $user = UserRepository::getUser($username, $this->container);  | 
            ||
| 448 | |||
| 449 | // Allow querying for any IP, currently with no edit count limitation...  | 
            ||
| 450 | // Once T188677 is resolved IPs will be affected by the EXPLAIN results.  | 
            ||
| 451 |         if ($user->isAnon()) { | 
            ||
| 452 | // Validate CIDR limits.  | 
            ||
| 453 |             if (!$user->isQueryableRange()) { | 
            ||
| 454 | $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;  | 
            ||
| 455 | $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username');  | 
            ||
| 456 | }  | 
            ||
| 457 | return $user;  | 
            ||
| 458 | }  | 
            ||
| 459 | |||
| 460 | $originalParams = $this->params;  | 
            ||
| 461 | |||
| 462 | // Don't continue if the user doesn't exist.  | 
            ||
| 463 |         if ($this->project && !$user->existsOnProject($this->project)) { | 
            ||
| 464 | $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');  | 
            ||
| 465 | }  | 
            ||
| 466 | |||
| 467 | // Reject users with a crazy high edit count.  | 
            ||
| 468 | if (isset($this->tooHighEditCountAction) &&  | 
            ||
| 469 | !in_array($this->controllerAction, $this->tooHighEditCountActionBlacklist) &&  | 
            ||
| 470 | $user->hasTooManyEdits($this->project)  | 
            ||
| 471 |         ) { | 
            ||
| 472 | /** TODO: Somehow get this to use self::throwXtoolsException */  | 
            ||
| 473 | |||
| 474 | // If redirecting to a different controller, show an informative message accordingly.  | 
            ||
| 475 |             if ($this->tooHighEditCountAction !== $this->getIndexRoute()) { | 
            ||
| 476 | // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,  | 
            ||
| 477 | // so this bit is hardcoded. We need to instead give the i18n key of the route.  | 
            ||
| 478 |                 $redirMsg = $this->i18n->msg('too-many-edits-redir', [ | 
            ||
| 479 |                     $this->i18n->msg('tool-simpleeditcounter'), | 
            ||
| 480 | ]);  | 
            ||
| 481 |                 $msg = $this->i18n->msg('too-many-edits', [ | 
            ||
| 482 | $this->i18n->numberFormat($user->maxEdits()),  | 
            ||
| 483 | ]).'. '.$redirMsg;  | 
            ||
| 484 |                 $this->addFlashMessage('danger', $msg); | 
            ||
| 485 |             } else { | 
            ||
| 486 |                 $this->addFlashMessage('danger', 'too-many-edits', [ | 
            ||
| 487 | $this->i18n->numberFormat($user->maxEdits()),  | 
            ||
| 488 | ]);  | 
            ||
| 489 | |||
| 490 | // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).  | 
            ||
| 491 | unset($this->params['username']);  | 
            ||
| 492 | }  | 
            ||
| 493 | |||
| 494 | // Clear flash bag for API responses, since they get intercepted in ExceptionListener  | 
            ||
| 495 | // and would otherwise be shown in subsequent requests.  | 
            ||
| 496 |             if ($this->isApi) { | 
            ||
| 497 |                 $this->get('session')->getFlashBag()->clear(); | 
            ||
| 498 | }  | 
            ||
| 499 | |||
| 500 | throw new XtoolsHttpException(  | 
            ||
| 501 | 'User has made too many edits! Maximum '.$user->maxEdits(),  | 
            ||
| 502 | $this->generateUrl($this->tooHighEditCountAction, $this->params),  | 
            ||
| 503 | $originalParams,  | 
            ||
| 504 | $this->isApi  | 
            ||
| 505 | );  | 
            ||
| 506 | }  | 
            ||
| 507 | |||
| 508 | return $user;  | 
            ||
| 509 | }  | 
            ||
| 510 | |||
| 511 | /**  | 
            ||
| 512 | * Get a Page instance from the given page title, and validate that it exists.  | 
            ||
| 513 | * @param string $pageTitle  | 
            ||
| 514 | * @return Page  | 
            ||
| 515 | * @throws XtoolsHttpException  | 
            ||
| 516 | */  | 
            ||
| 517 | public function validatePage(string $pageTitle): Page  | 
            ||
| 518 |     { | 
            ||
| 519 | $page = new Page($this->project, $pageTitle);  | 
            ||
| 520 | $pageRepo = new PageRepository();  | 
            ||
| 521 | $pageRepo->setContainer($this->container);  | 
            ||
| 522 | $page->setRepository($pageRepo);  | 
            ||
| 523 | |||
| 524 |         if (!$page->exists()) { | 
            ||
| 525 | $this->throwXtoolsException(  | 
            ||
| 526 | $this->getIndexRoute(),  | 
            ||
| 527 | 'no-result',  | 
            ||
| 528 | [$this->params['page'] ?? null],  | 
            ||
| 529 | 'page'  | 
            ||
| 530 | );  | 
            ||
| 531 | }  | 
            ||
| 532 | |||
| 533 | return $page;  | 
            ||
| 534 | }  | 
            ||
| 535 | |||
| 536 | /**  | 
            ||
| 537 | * Throw an XtoolsHttpException, which the given error message and redirects to specified action.  | 
            ||
| 538 | * @param string $redirectAction Name of action to redirect to.  | 
            ||
| 539 | * @param string $message i18n key of error message. Shown in API responses.  | 
            ||
| 540 | * If no message with this key exists, $message is shown as-is.  | 
            ||
| 541 | * @param array $messageParams  | 
            ||
| 542 | * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.  | 
            ||
| 543 | * @throws XtoolsHttpException  | 
            ||
| 544 | */  | 
            ||
| 545 | public function throwXtoolsException(  | 
            ||
| 573 | );  | 
            ||
| 574 | }  | 
            ||
| 575 | |||
| 576 | /**  | 
            ||
| 577 | * Get the first error message stored in the session's FlashBag.  | 
            ||
| 578 | * @return string  | 
            ||
| 579 | */  | 
            ||
| 580 | public function getFlashMessage(): string  | 
            ||
| 581 |     { | 
            ||
| 582 |         $key = $this->get('session')->getFlashBag()->get('danger')[0]; | 
            ||
| 583 | $param = null;  | 
            ||
| 584 | |||
| 585 |         if (is_array($key)) { | 
            ||
| 586 | [$key, $param] = $key;  | 
            ||
| 587 | }  | 
            ||
| 588 | |||
| 589 |         return $this->render('message.twig', [ | 
            ||
| 590 | 'key' => $key,  | 
            ||
| 591 | 'params' => [$param],  | 
            ||
| 592 | ])->getContent();  | 
            ||
| 593 | }  | 
            ||
| 594 | |||
| 595 | /******************  | 
            ||
| 596 | * PARSING PARAMS *  | 
            ||
| 597 | ******************/  | 
            ||
| 598 | |||
| 599 | /**  | 
            ||
| 600 | * Get all standardized parameters from the Request, either via URL query string or routing.  | 
            ||
| 601 | * @return string[]  | 
            ||
| 602 | */  | 
            ||
| 603 | public function getParams(): array  | 
            ||
| 604 |     { | 
            ||
| 605 | $paramsToCheck = [  | 
            ||
| 606 | 'project',  | 
            ||
| 607 | 'username',  | 
            ||
| 608 | 'namespace',  | 
            ||
| 609 | 'page',  | 
            ||
| 610 | 'categories',  | 
            ||
| 611 | 'group',  | 
            ||
| 612 | 'redirects',  | 
            ||
| 613 | 'deleted',  | 
            ||
| 614 | 'start',  | 
            ||
| 615 | 'end',  | 
            ||
| 616 | 'offset',  | 
            ||
| 617 | 'limit',  | 
            ||
| 618 | 'format',  | 
            ||
| 619 | 'tool',  | 
            ||
| 620 | 'tools',  | 
            ||
| 621 | 'q',  | 
            ||
| 622 | 'include_pattern',  | 
            ||
| 623 | 'exclude_pattern',  | 
            ||
| 624 | |||
| 625 | // Legacy parameters.  | 
            ||
| 626 | 'user',  | 
            ||
| 627 | 'name',  | 
            ||
| 628 | 'article',  | 
            ||
| 629 | 'wiki',  | 
            ||
| 630 | 'wikifam',  | 
            ||
| 631 | 'lang',  | 
            ||
| 632 | 'wikilang',  | 
            ||
| 633 | 'begin',  | 
            ||
| 634 | ];  | 
            ||
| 635 | |||
| 636 | /** @var string[] $params Each parameter that was detected along with its value. */  | 
            ||
| 637 | $params = [];  | 
            ||
| 638 | |||
| 639 |         foreach ($paramsToCheck as $param) { | 
            ||
| 640 | // Pull in either from URL query string or route.  | 
            ||
| 641 | $value = $this->request->query->get($param) ?: $this->request->get($param);  | 
            ||
| 642 | |||
| 643 |             // Only store if value is given ('namespace' or 'username' could be '0'). | 
            ||
| 644 |             if (null !== $value && '' !== $value) { | 
            ||
| 645 | $params[$param] = rawurldecode((string)$value);  | 
            ||
| 646 | }  | 
            ||
| 647 | }  | 
            ||
| 648 | |||
| 649 | return $params;  | 
            ||
| 650 | }  | 
            ||
| 651 | |||
| 652 | /**  | 
            ||
| 653 | * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',  | 
            ||
| 654 | * along with their legacy counterparts (e.g. 'lang' and 'wiki').  | 
            ||
| 655 | * @return string[] Normalized parameters (no legacy params).  | 
            ||
| 656 | */  | 
            ||
| 657 | public function parseQueryParams(): array  | 
            ||
| 658 |     { | 
            ||
| 659 | /** @var string[] $params Each parameter and value that was detected. */  | 
            ||
| 660 | $params = $this->getParams();  | 
            ||
| 661 | |||
| 662 | // Covert any legacy parameters, if present.  | 
            ||
| 663 | $params = $this->convertLegacyParams($params);  | 
            ||
| 664 | |||
| 665 | // Remove blank values.  | 
            ||
| 666 |         return array_filter($params, function ($param) { | 
            ||
| 667 | // 'namespace' or 'username' could be '0'.  | 
            ||
| 668 | return null !== $param && '' !== $param;  | 
            ||
| 669 | });  | 
            ||
| 670 | }  | 
            ||
| 671 | |||
| 672 | /**  | 
            ||
| 673 | * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays before  | 
            ||
| 674 | * $end if not present, and makes $end the current time if not present.  | 
            ||
| 675 | * The date range will not exceed $this->maxDays days, if this public class property is set.  | 
            ||
| 676 | * @param int|string|false $start Unix timestamp or string accepted by strtotime.  | 
            ||
| 677 | * @param int|string|false $end Unix timestamp or string accepted by strtotime.  | 
            ||
| 678 | * @return int[] Start and end date as UTC timestamps.  | 
            ||
| 679 | */  | 
            ||
| 680 | public function getUnixFromDateParams($start, $end): array  | 
            ||
| 681 |     { | 
            ||
| 682 |         $today = strtotime('today midnight'); | 
            ||
| 683 | |||
| 684 | // start time should not be in the future.  | 
            ||
| 685 | $startTime = min(  | 
            ||
| 686 | is_int($start) ? $start : strtotime((string)$start),  | 
            ||
| 687 | $today  | 
            ||
| 688 | );  | 
            ||
| 689 | |||
| 690 | // end time defaults to now, and will not be in the future.  | 
            ||
| 691 | $endTime = min(  | 
            ||
| 692 | (is_int($end) ? $end : strtotime((string)$end)) ?: $today,  | 
            ||
| 693 | $today  | 
            ||
| 694 | );  | 
            ||
| 695 | |||
| 696 | // Default to $this->defaultDays or $this->maxDays before end time if start is not present.  | 
            ||
| 697 | $daysOffset = $this->defaultDays ?? $this->maxDays;  | 
            ||
| 698 |         if (false === $startTime && is_int($daysOffset)) { | 
            ||
| 699 |             $startTime = strtotime("-$daysOffset days", $endTime); | 
            ||
| 700 | }  | 
            ||
| 701 | |||
| 702 | // Default to $this->defaultDays or $this->maxDays after start time if end is not present.  | 
            ||
| 703 |         if (false === $end && is_int($daysOffset)) { | 
            ||
| 704 | $endTime = min(  | 
            ||
| 705 |                 strtotime("+$daysOffset days", $startTime), | 
            ||
| 706 | $today  | 
            ||
| 707 | );  | 
            ||
| 708 | }  | 
            ||
| 709 | |||
| 710 | // Reverse if start date is after end date.  | 
            ||
| 711 |         if ($startTime > $endTime && false !== $startTime && false !== $end) { | 
            ||
| 712 | $newEndTime = $startTime;  | 
            ||
| 713 | $startTime = $endTime;  | 
            ||
| 714 | $endTime = $newEndTime;  | 
            ||
| 715 | }  | 
            ||
| 716 | |||
| 717 | // Finally, don't let the date range exceed $this->maxDays.  | 
            ||
| 718 |         $startObj = DateTime::createFromFormat('U', (string)$startTime); | 
            ||
| 719 |         $endObj = DateTime::createFromFormat('U', (string)$endTime); | 
            ||
| 720 |         if (is_int($this->maxDays) && $startObj->diff($endObj)->days > $this->maxDays) { | 
            ||
| 721 | // Show warnings that the date range was truncated.  | 
            ||
| 722 |             $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays]); | 
            ||
| 723 | |||
| 724 |             $startTime = strtotime("-$this->maxDays days", $endTime); | 
            ||
| 725 | }  | 
            ||
| 726 | |||
| 727 | return [$startTime, $endTime];  | 
            ||
| 728 | }  | 
            ||
| 729 | |||
| 730 | /**  | 
            ||
| 731 | * Given the params hash, normalize any legacy parameters to their modern equivalent.  | 
            ||
| 732 | * @param string[] $params  | 
            ||
| 733 | * @return string[]  | 
            ||
| 734 | */  | 
            ||
| 735 | private function convertLegacyParams(array $params): array  | 
            ||
| 736 |     { | 
            ||
| 737 | $paramMap = [  | 
            ||
| 738 | 'user' => 'username',  | 
            ||
| 739 | 'name' => 'username',  | 
            ||
| 740 | 'article' => 'page',  | 
            ||
| 741 | 'begin' => 'start',  | 
            ||
| 742 | |||
| 743 | // Copy super legacy project params to legacy so we can concatenate below.  | 
            ||
| 744 | 'wikifam' => 'wiki',  | 
            ||
| 745 | 'wikilang' => 'lang',  | 
            ||
| 746 | ];  | 
            ||
| 747 | |||
| 748 | // Copy legacy parameters to modern equivalent.  | 
            ||
| 749 |         foreach ($paramMap as $legacy => $modern) { | 
            ||
| 750 |             if (isset($params[$legacy])) { | 
            ||
| 751 | $params[$modern] = $params[$legacy];  | 
            ||
| 752 | unset($params[$legacy]);  | 
            ||
| 753 | }  | 
            ||
| 754 | }  | 
            ||
| 755 | |||
| 756 | // Separate parameters for language and wiki.  | 
            ||
| 757 |         if (isset($params['wiki']) && isset($params['lang'])) { | 
            ||
| 758 | // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',  | 
            ||
| 759 | // so we must remove leading periods and trailing .org's.  | 
            ||
| 760 | $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';  | 
            ||
| 761 | |||
| 762 | /** @var string[] $languagelessProjects Projects for which there is no specific language association. */  | 
            ||
| 763 |             $languagelessProjects = $this->container->getParameter('app.multilingual_wikis'); | 
            ||
| 764 | |||
| 765 | // Prepend language if applicable.  | 
            ||
| 766 |             if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) { | 
            ||
| 767 | $params['project'] = $params['lang'].'.'.$params['project'];  | 
            ||
| 768 | }  | 
            ||
| 769 | |||
| 770 | unset($params['wiki']);  | 
            ||
| 771 | unset($params['lang']);  | 
            ||
| 772 | }  | 
            ||
| 773 | |||
| 774 | return $params;  | 
            ||
| 775 | }  | 
            ||
| 776 | |||
| 777 | /************************  | 
            ||
| 778 | * FORMATTING RESPONSES *  | 
            ||
| 779 | ************************/  | 
            ||
| 780 | |||
| 781 | /**  | 
            ||
| 782 | * Get the rendered template for the requested format. This method also updates the cookies.  | 
            ||
| 783 | * @param string $templatePath Path to template without format,  | 
            ||
| 784 | * such as '/editCounter/latest_global'.  | 
            ||
| 785 | * @param array $ret Data that should be passed to the view.  | 
            ||
| 786 | * @return Response  | 
            ||
| 787 | * @codeCoverageIgnore  | 
            ||
| 788 | */  | 
            ||
| 789 | public function getFormattedResponse(string $templatePath, array $ret): Response  | 
            ||
| 790 |     { | 
            ||
| 791 |         $format = $this->request->query->get('format', 'html'); | 
            ||
| 792 |         if ('' == $format) { | 
            ||
| 793 | // The default above doesn't work when the 'format' parameter is blank.  | 
            ||
| 794 | $format = 'html';  | 
            ||
| 795 | }  | 
            ||
| 796 | |||
| 797 | // Merge in common default parameters, giving $ret (from the caller) the priority.  | 
            ||
| 798 | $ret = array_merge([  | 
            ||
| 799 | 'project' => $this->project,  | 
            ||
| 800 | 'user' => $this->user,  | 
            ||
| 801 | 'page' => $this->page,  | 
            ||
| 802 | 'namespace' => $this->namespace,  | 
            ||
| 803 | 'start' => $this->start,  | 
            ||
| 804 | 'end' => $this->end,  | 
            ||
| 805 | ], $ret);  | 
            ||
| 806 | |||
| 807 | $formatMap = [  | 
            ||
| 808 | 'wikitext' => 'text/plain',  | 
            ||
| 809 | 'csv' => 'text/csv',  | 
            ||
| 810 | 'tsv' => 'text/tab-separated-values',  | 
            ||
| 811 | 'json' => 'application/json',  | 
            ||
| 812 | ];  | 
            ||
| 813 | |||
| 814 | $response = new Response();  | 
            ||
| 815 | |||
| 816 | // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.  | 
            ||
| 817 | $this->setCookies($response);  | 
            ||
| 818 | |||
| 819 | // If requested format does not exist, assume HTML.  | 
            ||
| 820 |         if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) { | 
            ||
| 821 | $format = 'html';  | 
            ||
| 822 | }  | 
            ||
| 823 | |||
| 824 |         $response = $this->render("$templatePath.$format.twig", $ret, $response); | 
            ||
| 825 | |||
| 826 | $contentType = $formatMap[$format] ?? 'text/html';  | 
            ||
| 827 |         $response->headers->set('Content-Type', $contentType); | 
            ||
| 828 | |||
| 829 |         if (in_array($format, ['csv', 'tsv'])) { | 
            ||
| 830 | $filename = $this->getFilenameForRequest();  | 
            ||
| 831 | $response->headers->set(  | 
            ||
| 832 | 'Content-Disposition',  | 
            ||
| 833 |                 "attachment; filename=\"{$filename}.$format\"" | 
            ||
| 834 | );  | 
            ||
| 835 | }  | 
            ||
| 836 | |||
| 837 | return $response;  | 
            ||
| 838 | }  | 
            ||
| 839 | |||
| 840 | /**  | 
            ||
| 841 | * Returns given filename from the current Request, with problematic characters filtered out.  | 
            ||
| 842 | * @return string  | 
            ||
| 843 | */  | 
            ||
| 844 | private function getFilenameForRequest(): string  | 
            ||
| 845 |     { | 
            ||
| 846 | $filename = trim($this->request->getPathInfo(), '/');  | 
            ||
| 847 |         return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename)); | 
            ||
| 848 | }  | 
            ||
| 849 | |||
| 850 | /**  | 
            ||
| 851 | * Return a JsonResponse object pre-supplied with the requested params.  | 
            ||
| 852 | * @param array $data  | 
            ||
| 853 | * @return JsonResponse  | 
            ||
| 854 | */  | 
            ||
| 855 | public function getFormattedApiResponse(array $data): JsonResponse  | 
            ||
| 856 |     { | 
            ||
| 857 | $response = new JsonResponse();  | 
            ||
| 858 | $response->setEncodingOptions(JSON_NUMERIC_CHECK);  | 
            ||
| 859 | $response->setStatusCode(Response::HTTP_OK);  | 
            ||
| 860 | |||
| 861 | // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).  | 
            ||
| 862 |         if ($this->user && $this->user->isIpRange()) { | 
            ||
| 863 | $this->params['username'] = $this->user->getUsername();  | 
            ||
| 864 | }  | 
            ||
| 865 | |||
| 866 | $elapsedTime = round(  | 
            ||
| 867 |             microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'), | 
            ||
| 868 | 3  | 
            ||
| 869 | );  | 
            ||
| 870 | |||
| 871 | // Any pipe-separated values should be returned as an array.  | 
            ||
| 872 |         foreach ($this->params as $param => $value) { | 
            ||
| 873 |             if (is_string($value) && false !== strpos($value, '|')) { | 
            ||
| 874 |                 $this->params[$param] = explode('|', $value); | 
            ||
| 875 | }  | 
            ||
| 876 | }  | 
            ||
| 877 | |||
| 878 | $ret = array_merge($this->params, [  | 
            ||
| 879 | // In some controllers, $this->params['project'] may be overridden with a Project object.  | 
            ||
| 880 | 'project' => $this->project->getDomain(),  | 
            ||
| 881 | ], $data, ['elapsed_time' => $elapsedTime]);  | 
            ||
| 882 | |||
| 883 | // Merge in flash messages, putting them at the top.  | 
            ||
| 884 |         $flashes = $this->get('session')->getFlashBag()->peekAll(); | 
            ||
| 885 | $ret = array_merge($flashes, $ret);  | 
            ||
| 886 | |||
| 887 | // Flashes now can be cleared after merging into the response.  | 
            ||
| 888 |         $this->get('session')->getFlashBag()->clear(); | 
            ||
| 889 | |||
| 890 | $response->setData($ret);  | 
            ||
| 891 | |||
| 892 | return $response;  | 
            ||
| 893 | }  | 
            ||
| 894 | |||
| 895 | /**  | 
            ||
| 896 | * Used to standardized the format of API responses that contain revisions.  | 
            ||
| 897 | * Adds a 'full_page_title' key and value to each entry in $data.  | 
            ||
| 898 | * If there are as many entries in $data as there are $this->limit, pagination is assumed  | 
            ||
| 899 | * and a 'continue' key is added to the end of the response body.  | 
            ||
| 900 | * @param string $key Key accessing the list of revisions in $data.  | 
            ||
| 901 | * @param array $out Whatever data needs to appear above the $data in the response body.  | 
            ||
| 902 | * @param array $data The data set itself.  | 
            ||
| 903 | * @return array  | 
            ||
| 904 | */  | 
            ||
| 905 | public function addFullPageTitlesAndContinue(string $key, array $out, array $data): array  | 
            ||
| 927 | }  | 
            ||
| 928 | |||
| 929 | /*********  | 
            ||
| 930 | * OTHER *  | 
            ||
| 931 | *********/  | 
            ||
| 932 | |||
| 933 | /**  | 
            ||
| 934 | * Record usage of an API endpoint.  | 
            ||
| 935 | * @param string $endpoint  | 
            ||
| 936 | * @codeCoverageIgnore  | 
            ||
| 937 | */  | 
            ||
| 938 | public function recordApiUsage(string $endpoint): void  | 
            ||
| 964 | // Do nothing. API response should still be returned rather than erroring out.  | 
            ||
| 965 | }  | 
            ||
| 966 | }  | 
            ||
| 967 | |||
| 968 | /**  | 
            ||
| 969 | * Add a flash message.  | 
            ||
| 970 | * @param string $type  | 
            ||
| 971 | * @param string $key i18n key or raw message.  | 
            ||
| 972 | * @param array $vars  | 
            ||
| 973 | */  | 
            ||
| 974 | public function addFlashMessage(string $type, string $key, array $vars = []): void  | 
            ||
| 979 | );  | 
            ||
| 980 | }  | 
            ||
| 981 | }  | 
            ||
| 982 |