| Total Complexity | 108 | 
| Total Lines | 902 | 
| Duplicated Lines | 0 % | 
| Changes | 9 | ||
| Bugs | 0 | Features | 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 | /** @var bool Is the current request a subrequest? */  | 
            ||
| 94 | protected $isSubRequest;  | 
            ||
| 95 | |||
| 96 | /**  | 
            ||
| 97 | * Stores user preferences such default project.  | 
            ||
| 98 | * This may get altered from the Request and updated in the Response.  | 
            ||
| 99 | * @var array  | 
            ||
| 100 | */  | 
            ||
| 101 | protected $cookies = [  | 
            ||
| 102 | 'XtoolsProject' => null,  | 
            ||
| 103 | ];  | 
            ||
| 104 | |||
| 105 | /**  | 
            ||
| 106 | * This activates the 'too high edit count' functionality. This property represents the  | 
            ||
| 107 | * action that should be redirected to if the user has too high of an edit count.  | 
            ||
| 108 | * @var string  | 
            ||
| 109 | */  | 
            ||
| 110 | protected $tooHighEditCountAction;  | 
            ||
| 111 | |||
| 112 | /** @var array Actions that are exempt from edit count limitations. */  | 
            ||
| 113 | protected $tooHighEditCountActionBlacklist = [];  | 
            ||
| 114 | |||
| 115 | /**  | 
            ||
| 116 | * Actions that require the target user to opt in to the restricted statistics.  | 
            ||
| 117 | * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats  | 
            ||
| 118 | * @var string[]  | 
            ||
| 119 | */  | 
            ||
| 120 | protected $restrictedActions = [];  | 
            ||
| 121 | |||
| 122 | /**  | 
            ||
| 123 | * XtoolsController::validateProject() will ensure the given project matches one of these domains,  | 
            ||
| 124 | * instead of any valid project.  | 
            ||
| 125 | * @var string[]  | 
            ||
| 126 | */  | 
            ||
| 127 | protected $supportedProjects;  | 
            ||
| 128 | |||
| 129 | /**  | 
            ||
| 130 | * Require the tool's index route (initial form) be defined here. This should also  | 
            ||
| 131 | * be the name of the associated model, if present.  | 
            ||
| 132 | * @return string  | 
            ||
| 133 | */  | 
            ||
| 134 | abstract protected function getIndexRoute(): string;  | 
            ||
| 135 | |||
| 136 | /**  | 
            ||
| 137 | * XtoolsController constructor.  | 
            ||
| 138 | * @param RequestStack $requestStack  | 
            ||
| 139 | * @param ContainerInterface $container  | 
            ||
| 140 | * @param I18nHelper $i18n  | 
            ||
| 141 | */  | 
            ||
| 142 | public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)  | 
            ||
| 143 |     { | 
            ||
| 144 | $this->request = $requestStack->getCurrentRequest();  | 
            ||
| 145 | $this->container = $container;  | 
            ||
| 146 | $this->i18n = $i18n;  | 
            ||
| 147 | $this->params = $this->parseQueryParams();  | 
            ||
| 148 | |||
| 149 | // Parse out the name of the controller and action.  | 
            ||
| 150 | $pattern = "#::([a-zA-Z]*)Action#";  | 
            ||
| 151 | $matches = [];  | 
            ||
| 152 | // The blank string here only happens in the unit tests, where the request may not be made to an action.  | 
            ||
| 153 |         preg_match($pattern, $this->request->get('_controller') ?? '', $matches); | 
            ||
| 154 | $this->controllerAction = $matches[1] ?? '';  | 
            ||
| 155 | |||
| 156 | // Whether the action is an API action.  | 
            ||
| 157 | $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;  | 
            ||
| 158 | |||
| 159 | // Whether we're making a subrequest (the view makes a request to another action).  | 
            ||
| 160 |         $this->isSubRequest = $this->request->get('htmlonly') | 
            ||
| 161 |             || null !== $this->get('request_stack')->getParentRequest(); | 
            ||
| 162 | |||
| 163 | // Disallow AJAX (unless it's an API or subrequest).  | 
            ||
| 164 | $this->checkIfAjax();  | 
            ||
| 165 | |||
| 166 | // Load user options from cookies.  | 
            ||
| 167 | $this->loadCookies();  | 
            ||
| 168 | |||
| 169 | // Set the class-level properties based on params.  | 
            ||
| 170 |         if (false !== strpos(strtolower($this->controllerAction), 'index')) { | 
            ||
| 171 | // Index pages should only set the project, and no other class properties.  | 
            ||
| 172 | $this->setProject($this->getProjectFromQuery());  | 
            ||
| 173 | |||
| 174 | // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to  | 
            ||
| 175 | // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as  | 
            ||
| 176 | // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'.  | 
            ||
| 177 | // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange.  | 
            ||
| 178 |             if (isset($this->params['username']) && IPUtils::isValidRange($this->params['username'])) { | 
            ||
| 179 | $this->params['username'] = 'ipr-'.$this->params['username'];  | 
            ||
| 180 | }  | 
            ||
| 181 |         } else { | 
            ||
| 182 | $this->setProperties(); // Includes the project.  | 
            ||
| 183 | }  | 
            ||
| 184 | |||
| 185 | // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.  | 
            ||
| 186 | $this->checkRestrictedApiEndpoint();  | 
            ||
| 187 | }  | 
            ||
| 188 | |||
| 189 | /**  | 
            ||
| 190 | * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.  | 
            ||
| 191 | */  | 
            ||
| 192 | private function checkIfAjax(): void  | 
            ||
| 193 |     { | 
            ||
| 194 |         if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) { | 
            ||
| 195 | throw new HttpException(  | 
            ||
| 196 | 403,  | 
            ||
| 197 |                 $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API']) | 
            ||
| 198 | );  | 
            ||
| 199 | }  | 
            ||
| 200 | }  | 
            ||
| 201 | |||
| 202 | /**  | 
            ||
| 203 | * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.  | 
            ||
| 204 | * @throws XtoolsHttpException  | 
            ||
| 205 | */  | 
            ||
| 206 | private function checkRestrictedApiEndpoint(): void  | 
            ||
| 207 |     { | 
            ||
| 208 | $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);  | 
            ||
| 209 | |||
| 210 |         if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) { | 
            ||
| 211 | throw new XtoolsHttpException(  | 
            ||
| 212 |                 $this->i18n->msg('not-opted-in', [ | 
            ||
| 213 | $this->getOptedInPage()->getTitle(),  | 
            ||
| 214 |                     $this->i18n->msg('not-opted-in-link') . | 
            ||
| 215 | ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',  | 
            ||
| 216 |                     $this->i18n->msg('not-opted-in-login'), | 
            ||
| 217 | ]),  | 
            ||
| 218 | '',  | 
            ||
| 219 | $this->params,  | 
            ||
| 220 | true,  | 
            ||
| 221 | Response::HTTP_UNAUTHORIZED  | 
            ||
| 222 | );  | 
            ||
| 223 | }  | 
            ||
| 224 | }  | 
            ||
| 225 | |||
| 226 | /**  | 
            ||
| 227 | * Get the path to the opt-in page for restricted statistics.  | 
            ||
| 228 | * @return Page  | 
            ||
| 229 | */  | 
            ||
| 230 | protected function getOptedInPage(): Page  | 
            ||
| 231 |     { | 
            ||
| 232 | return $this->project  | 
            ||
| 233 | ->getRepository()  | 
            ||
| 234 | ->getPage($this->project, $this->project->userOptInPage($this->user));  | 
            ||
| 235 | }  | 
            ||
| 236 | |||
| 237 | /***********  | 
            ||
| 238 | * COOKIES *  | 
            ||
| 239 | ***********/  | 
            ||
| 240 | |||
| 241 | /**  | 
            ||
| 242 | * Load user preferences from the associated cookies.  | 
            ||
| 243 | */  | 
            ||
| 244 | private function loadCookies(): void  | 
            ||
| 245 |     { | 
            ||
| 246 | // Not done for subrequests.  | 
            ||
| 247 |         if ($this->isSubRequest) { | 
            ||
| 248 | return;  | 
            ||
| 249 | }  | 
            ||
| 250 | |||
| 251 |         foreach (array_keys($this->cookies) as $name) { | 
            ||
| 252 | $this->cookies[$name] = $this->request->cookies->get($name);  | 
            ||
| 253 | }  | 
            ||
| 254 | }  | 
            ||
| 255 | |||
| 256 | /**  | 
            ||
| 257 | * Set cookies on the given Response.  | 
            ||
| 258 | * @param Response $response  | 
            ||
| 259 | */  | 
            ||
| 260 | private function setCookies(Response &$response): void  | 
            ||
| 261 |     { | 
            ||
| 262 | // Not done for subrequests.  | 
            ||
| 263 |         if ($this->isSubRequest) { | 
            ||
| 264 | return;  | 
            ||
| 265 | }  | 
            ||
| 266 | |||
| 267 |         foreach ($this->cookies as $name => $value) { | 
            ||
| 268 | $response->headers->setCookie(  | 
            ||
| 269 | Cookie::create($name, $value)  | 
            ||
| 270 | );  | 
            ||
| 271 | }  | 
            ||
| 272 | }  | 
            ||
| 273 | |||
| 274 | /**  | 
            ||
| 275 | * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will  | 
            ||
| 276 | * later get set on the Response headers in self::getFormattedResponse().  | 
            ||
| 277 | * @param Project $project  | 
            ||
| 278 | */  | 
            ||
| 279 | private function setProject(Project $project): void  | 
            ||
| 280 |     { | 
            ||
| 281 | // TODO: Remove after deprecated routes are retired.  | 
            ||
| 282 |         if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) { | 
            ||
| 283 | return;  | 
            ||
| 284 | }  | 
            ||
| 285 | |||
| 286 | $this->project = $project;  | 
            ||
| 287 | $this->cookies['XtoolsProject'] = $project->getDomain();  | 
            ||
| 288 | }  | 
            ||
| 289 | |||
| 290 | /****************************  | 
            ||
| 291 | * SETTING CLASS PROPERTIES *  | 
            ||
| 292 | ****************************/  | 
            ||
| 293 | |||
| 294 | /**  | 
            ||
| 295 | * Normalize all common parameters used by the controllers and set class properties.  | 
            ||
| 296 | */  | 
            ||
| 297 | private function setProperties(): void  | 
            ||
| 298 |     { | 
            ||
| 299 | $this->namespace = $this->params['namespace'] ?? null;  | 
            ||
| 300 | |||
| 301 | // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).  | 
            ||
| 302 |         if (isset($this->params['offset'])) { | 
            ||
| 303 | $this->offset = strtotime($this->params['offset']);  | 
            ||
| 304 | }  | 
            ||
| 305 | |||
| 306 | // Limit need to be an int.  | 
            ||
| 307 |         if (isset($this->params['limit'])) { | 
            ||
| 308 | // Normalize.  | 
            ||
| 309 | $this->params['limit'] = max(0, (int)$this->params['limit']);  | 
            ||
| 310 | $this->limit = $this->params['limit'];  | 
            ||
| 311 | }  | 
            ||
| 312 | |||
| 313 |         if (isset($this->params['project'])) { | 
            ||
| 314 | $this->setProject($this->validateProject($this->params['project']));  | 
            ||
| 315 |         } elseif (null !== $this->cookies['XtoolsProject']) { | 
            ||
| 316 | // Set from cookie.  | 
            ||
| 317 | $this->setProject(  | 
            ||
| 318 | $this->validateProject($this->cookies['XtoolsProject'])  | 
            ||
| 319 | );  | 
            ||
| 320 | }  | 
            ||
| 321 | |||
| 322 |         if (isset($this->params['username'])) { | 
            ||
| 323 | $this->user = $this->validateUser($this->params['username']);  | 
            ||
| 324 | }  | 
            ||
| 325 |         if (isset($this->params['page'])) { | 
            ||
| 326 | $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);  | 
            ||
| 327 | }  | 
            ||
| 328 | |||
| 329 | $this->setDates();  | 
            ||
| 330 | }  | 
            ||
| 331 | |||
| 332 | /**  | 
            ||
| 333 | * Set class properties for dates, if such params were passed in.  | 
            ||
| 334 | */  | 
            ||
| 335 | private function setDates(): void  | 
            ||
| 336 |     { | 
            ||
| 337 | $start = $this->params['start'] ?? false;  | 
            ||
| 338 | $end = $this->params['end'] ?? false;  | 
            ||
| 339 |         if ($start || $end || null !== $this->maxDays) { | 
            ||
| 340 | [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);  | 
            ||
| 341 | |||
| 342 | // Set $this->params accordingly too, so that for instance API responses will include it.  | 
            ||
| 343 |             $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false; | 
            ||
| 344 |             $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false; | 
            ||
| 345 | }  | 
            ||
| 346 | }  | 
            ||
| 347 | |||
| 348 | /**  | 
            ||
| 349 | * Construct a fully qualified page title given the namespace and title.  | 
            ||
| 350 | * @param int|string $ns Namespace ID.  | 
            ||
| 351 | * @param string $title Page title.  | 
            ||
| 352 | * @param bool $rawTitle Return only the title (and not a Page).  | 
            ||
| 353 | * @return Page|string  | 
            ||
| 354 | */  | 
            ||
| 355 | protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)  | 
            ||
| 356 |     { | 
            ||
| 357 |         if (0 === (int)$ns) { | 
            ||
| 358 | return $rawTitle ? $title : $this->validatePage($title);  | 
            ||
| 359 | }  | 
            ||
| 360 | |||
| 361 | // Prepend namespace and strip out duplicates.  | 
            ||
| 362 |         $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown'); | 
            ||
| 363 |         $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title); | 
            ||
| 364 | return $rawTitle ? $title : $this->validatePage($title);  | 
            ||
| 365 | }  | 
            ||
| 366 | |||
| 367 | /**  | 
            ||
| 368 | * Get a Project instance from the project string, using defaults if the given project string is invalid.  | 
            ||
| 369 | * @return Project  | 
            ||
| 370 | */  | 
            ||
| 371 | public function getProjectFromQuery(): Project  | 
            ||
| 372 |     { | 
            ||
| 373 | // Set default project so we can populate the namespace selector on index pages.  | 
            ||
| 374 | // Defaults to project stored in cookie, otherwise project specified in parameters.yml.  | 
            ||
| 375 |         if (isset($this->params['project'])) { | 
            ||
| 376 | $project = $this->params['project'];  | 
            ||
| 377 |         } elseif (null !== $this->cookies['XtoolsProject']) { | 
            ||
| 378 | $project = $this->cookies['XtoolsProject'];  | 
            ||
| 379 |         } else { | 
            ||
| 380 |             $project = $this->container->getParameter('default_project'); | 
            ||
| 381 | }  | 
            ||
| 382 | |||
| 383 | $projectData = ProjectRepository::getProject($project, $this->container);  | 
            ||
| 384 | |||
| 385 | // Revert back to defaults if we've established the given project was invalid.  | 
            ||
| 386 |         if (!$projectData->exists()) { | 
            ||
| 387 | $projectData = ProjectRepository::getProject(  | 
            ||
| 388 |                 $this->container->getParameter('default_project'), | 
            ||
| 389 | $this->container  | 
            ||
| 390 | );  | 
            ||
| 391 | }  | 
            ||
| 392 | |||
| 393 | return $projectData;  | 
            ||
| 394 | }  | 
            ||
| 395 | |||
| 396 | /*************************  | 
            ||
| 397 | * GETTERS / VALIDATIONS *  | 
            ||
| 398 | *************************/  | 
            ||
| 399 | |||
| 400 | /**  | 
            ||
| 401 | * Validate the given project, returning a Project if it is valid or false otherwise.  | 
            ||
| 402 | * @param string $projectQuery Project domain or database name.  | 
            ||
| 403 | * @return Project  | 
            ||
| 404 | * @throws XtoolsHttpException  | 
            ||
| 405 | */  | 
            ||
| 406 | public function validateProject(string $projectQuery): Project  | 
            ||
| 431 | }  | 
            ||
| 432 | |||
| 433 | /**  | 
            ||
| 434 | * Validate the given user, returning a User or Redirect if they don't exist.  | 
            ||
| 435 | * @param string $username  | 
            ||
| 436 | * @return User  | 
            ||
| 437 | * @throws XtoolsHttpException  | 
            ||
| 438 | */  | 
            ||
| 439 | public function validateUser(string $username): User  | 
            ||
| 440 |     { | 
            ||
| 441 | $user = UserRepository::getUser($username, $this->container);  | 
            ||
| 442 | |||
| 443 | // Allow querying for any IP, currently with no edit count limitation...  | 
            ||
| 444 | // Once T188677 is resolved IPs will be affected by the EXPLAIN results.  | 
            ||
| 445 |         if ($user->isAnon()) { | 
            ||
| 446 | // Validate CIDR limits.  | 
            ||
| 447 |             if (!$user->isQueryableRange()) { | 
            ||
| 448 | $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;  | 
            ||
| 449 | $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit]);  | 
            ||
| 450 | }  | 
            ||
| 451 | return $user;  | 
            ||
| 452 | }  | 
            ||
| 453 | |||
| 454 | $originalParams = $this->params;  | 
            ||
| 455 | |||
| 456 | // Don't continue if the user doesn't exist.  | 
            ||
| 457 |         if ($this->project && !$user->existsOnProject($this->project)) { | 
            ||
| 458 | $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');  | 
            ||
| 459 | }  | 
            ||
| 460 | |||
| 461 | // Reject users with a crazy high edit count.  | 
            ||
| 462 | if (isset($this->tooHighEditCountAction) &&  | 
            ||
| 463 | !in_array($this->controllerAction, $this->tooHighEditCountActionBlacklist) &&  | 
            ||
| 464 | $user->hasTooManyEdits($this->project)  | 
            ||
| 465 |         ) { | 
            ||
| 466 | /** TODO: Somehow get this to use self::throwXtoolsException */  | 
            ||
| 467 | |||
| 468 | // If redirecting to a different controller, show an informative message accordingly.  | 
            ||
| 469 |             if ($this->tooHighEditCountAction !== $this->getIndexRoute()) { | 
            ||
| 470 | // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,  | 
            ||
| 471 | // so this bit is hardcoded. We need to instead give the i18n key of the route.  | 
            ||
| 472 |                 $redirMsg = $this->i18n->msg('too-many-edits-redir', [ | 
            ||
| 473 |                     $this->i18n->msg('tool-simpleeditcounter'), | 
            ||
| 474 | ]);  | 
            ||
| 475 |                 $msg = $this->i18n->msg('too-many-edits', [ | 
            ||
| 476 | $this->i18n->numberFormat($user->maxEdits()),  | 
            ||
| 477 | ]).'. '.$redirMsg;  | 
            ||
| 478 |                 $this->addFlashMessage('danger', $msg); | 
            ||
| 479 |             } else { | 
            ||
| 480 |                 $this->addFlashMessage('danger', 'too-many-edits', [ | 
            ||
| 481 | $this->i18n->numberFormat($user->maxEdits()),  | 
            ||
| 482 | ]);  | 
            ||
| 483 | |||
| 484 | // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).  | 
            ||
| 485 | unset($this->params['username']);  | 
            ||
| 486 | }  | 
            ||
| 487 | |||
| 488 | // Clear flash bag for API responses, since they get intercepted in ExceptionListener  | 
            ||
| 489 | // and would otherwise be shown in subsequent requests.  | 
            ||
| 490 |             if ($this->isApi) { | 
            ||
| 491 |                 $this->get('session')->getFlashBag()->clear(); | 
            ||
| 492 | }  | 
            ||
| 493 | |||
| 494 | throw new XtoolsHttpException(  | 
            ||
| 495 | 'User has made too many edits! Maximum '.$user->maxEdits(),  | 
            ||
| 496 | $this->generateUrl($this->tooHighEditCountAction, $this->params),  | 
            ||
| 497 | $originalParams,  | 
            ||
| 498 | $this->isApi  | 
            ||
| 499 | );  | 
            ||
| 500 | }  | 
            ||
| 501 | |||
| 502 | return $user;  | 
            ||
| 503 | }  | 
            ||
| 504 | |||
| 505 | /**  | 
            ||
| 506 | * Get a Page instance from the given page title, and validate that it exists.  | 
            ||
| 507 | * @param string $pageTitle  | 
            ||
| 508 | * @return Page  | 
            ||
| 509 | * @throws XtoolsHttpException  | 
            ||
| 510 | */  | 
            ||
| 511 | public function validatePage(string $pageTitle): Page  | 
            ||
| 512 |     { | 
            ||
| 513 | $page = new Page($this->project, $pageTitle);  | 
            ||
| 514 | $pageRepo = new PageRepository();  | 
            ||
| 515 | $pageRepo->setContainer($this->container);  | 
            ||
| 516 | $page->setRepository($pageRepo);  | 
            ||
| 517 | |||
| 518 |         if (!$page->exists()) { | 
            ||
| 519 | $this->throwXtoolsException(  | 
            ||
| 520 | $this->getIndexRoute(),  | 
            ||
| 521 | 'no-result',  | 
            ||
| 522 | [$this->params['page'] ?? null],  | 
            ||
| 523 | 'page'  | 
            ||
| 524 | );  | 
            ||
| 525 | }  | 
            ||
| 526 | |||
| 527 | return $page;  | 
            ||
| 528 | }  | 
            ||
| 529 | |||
| 530 | /**  | 
            ||
| 531 | * Throw an XtoolsHttpException, which the given error message and redirects to specified action.  | 
            ||
| 532 | * @param string $redirectAction Name of action to redirect to.  | 
            ||
| 533 | * @param string $message i18n key of error message. Shown in API responses.  | 
            ||
| 534 | * If no message with this key exists, $message is shown as-is.  | 
            ||
| 535 | * @param array $messageParams  | 
            ||
| 536 | * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.  | 
            ||
| 537 | * @throws XtoolsHttpException  | 
            ||
| 538 | */  | 
            ||
| 539 | public function throwXtoolsException(  | 
            ||
| 540 | string $redirectAction,  | 
            ||
| 541 | string $message,  | 
            ||
| 542 | array $messageParams = [],  | 
            ||
| 543 | ?string $invalidParam = null  | 
            ||
| 544 |     ): void { | 
            ||
| 545 |         $this->addFlashMessage('danger', $message, $messageParams); | 
            ||
| 546 | $originalParams = $this->params;  | 
            ||
| 547 | |||
| 548 | // Remove invalid parameter if it was given.  | 
            ||
| 549 |         if (is_string($invalidParam)) { | 
            ||
| 550 | unset($this->params[$invalidParam]);  | 
            ||
| 551 | }  | 
            ||
| 552 | |||
| 553 | // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).  | 
            ||
| 554 | /**  | 
            ||
| 555 | * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.  | 
            ||
| 556 | * Then we don't even need to remove $invalidParam.  | 
            ||
| 557 | * Better, we should show the error on the results page, with no results.  | 
            ||
| 558 | */  | 
            ||
| 559 | unset($this->params['project']);  | 
            ||
| 560 | |||
| 561 | // Throw exception which will redirect to $redirectAction.  | 
            ||
| 562 | throw new XtoolsHttpException(  | 
            ||
| 563 | $this->i18n->msgIfExists($message, $messageParams),  | 
            ||
| 564 | $this->generateUrl($redirectAction, $this->params),  | 
            ||
| 565 | $originalParams,  | 
            ||
| 566 | $this->isApi  | 
            ||
| 567 | );  | 
            ||
| 568 | }  | 
            ||
| 569 | |||
| 570 | /**  | 
            ||
| 571 | * Get the first error message stored in the session's FlashBag.  | 
            ||
| 572 | * @return string  | 
            ||
| 573 | */  | 
            ||
| 574 | public function getFlashMessage(): string  | 
            ||
| 575 |     { | 
            ||
| 576 |         $key = $this->get('session')->getFlashBag()->get('danger')[0]; | 
            ||
| 577 | $param = null;  | 
            ||
| 578 | |||
| 579 |         if (is_array($key)) { | 
            ||
| 580 | [$key, $param] = $key;  | 
            ||
| 581 | }  | 
            ||
| 582 | |||
| 583 |         return $this->render('message.twig', [ | 
            ||
| 584 | 'key' => $key,  | 
            ||
| 585 | 'params' => [$param],  | 
            ||
| 586 | ])->getContent();  | 
            ||
| 587 | }  | 
            ||
| 588 | |||
| 589 | /******************  | 
            ||
| 590 | * PARSING PARAMS *  | 
            ||
| 591 | ******************/  | 
            ||
| 592 | |||
| 593 | /**  | 
            ||
| 594 | * Get all standardized parameters from the Request, either via URL query string or routing.  | 
            ||
| 595 | * @return string[]  | 
            ||
| 596 | */  | 
            ||
| 597 | public function getParams(): array  | 
            ||
| 598 |     { | 
            ||
| 599 | $paramsToCheck = [  | 
            ||
| 600 | 'project',  | 
            ||
| 601 | 'username',  | 
            ||
| 602 | 'namespace',  | 
            ||
| 603 | 'page',  | 
            ||
| 604 | 'categories',  | 
            ||
| 605 | 'group',  | 
            ||
| 606 | 'redirects',  | 
            ||
| 607 | 'deleted',  | 
            ||
| 608 | 'start',  | 
            ||
| 609 | 'end',  | 
            ||
| 610 | 'offset',  | 
            ||
| 611 | 'limit',  | 
            ||
| 612 | 'format',  | 
            ||
| 613 | 'tool',  | 
            ||
| 614 | 'tools',  | 
            ||
| 615 | 'q',  | 
            ||
| 616 | |||
| 617 | // Legacy parameters.  | 
            ||
| 618 | 'user',  | 
            ||
| 619 | 'name',  | 
            ||
| 620 | 'article',  | 
            ||
| 621 | 'wiki',  | 
            ||
| 622 | 'wikifam',  | 
            ||
| 623 | 'lang',  | 
            ||
| 624 | 'wikilang',  | 
            ||
| 625 | 'begin',  | 
            ||
| 626 | ];  | 
            ||
| 627 | |||
| 628 | /** @var string[] $params Each parameter that was detected along with its value. */  | 
            ||
| 629 | $params = [];  | 
            ||
| 630 | |||
| 631 |         foreach ($paramsToCheck as $param) { | 
            ||
| 632 | // Pull in either from URL query string or route.  | 
            ||
| 633 | $value = $this->request->query->get($param) ?: $this->request->get($param);  | 
            ||
| 634 | |||
| 635 |             // Only store if value is given ('namespace' or 'username' could be '0'). | 
            ||
| 636 |             if (null !== $value && '' !== $value) { | 
            ||
| 637 | $params[$param] = rawurldecode((string)$value);  | 
            ||
| 638 | }  | 
            ||
| 639 | }  | 
            ||
| 640 | |||
| 641 | return $params;  | 
            ||
| 642 | }  | 
            ||
| 643 | |||
| 644 | /**  | 
            ||
| 645 | * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',  | 
            ||
| 646 | * along with their legacy counterparts (e.g. 'lang' and 'wiki').  | 
            ||
| 647 | * @return string[] Normalized parameters (no legacy params).  | 
            ||
| 648 | */  | 
            ||
| 649 | public function parseQueryParams(): array  | 
            ||
| 650 |     { | 
            ||
| 651 | /** @var string[] $params Each parameter and value that was detected. */  | 
            ||
| 652 | $params = $this->getParams();  | 
            ||
| 653 | |||
| 654 | // Covert any legacy parameters, if present.  | 
            ||
| 655 | $params = $this->convertLegacyParams($params);  | 
            ||
| 656 | |||
| 657 | // Remove blank values.  | 
            ||
| 658 |         return array_filter($params, function ($param) { | 
            ||
| 659 | // 'namespace' or 'username' could be '0'.  | 
            ||
| 660 | return null !== $param && '' !== $param;  | 
            ||
| 661 | });  | 
            ||
| 662 | }  | 
            ||
| 663 | |||
| 664 | /**  | 
            ||
| 665 | * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays before  | 
            ||
| 666 | * $end if not present, and makes $end the current time if not present.  | 
            ||
| 667 | * The date range will not exceed $this->maxDays days, if this public class property is set.  | 
            ||
| 668 | * @param int|string|false $start Unix timestamp or string accepted by strtotime.  | 
            ||
| 669 | * @param int|string|false $end Unix timestamp or string accepted by strtotime.  | 
            ||
| 670 | * @return int[] Start and end date as UTC timestamps.  | 
            ||
| 671 | */  | 
            ||
| 672 | public function getUnixFromDateParams($start, $end): array  | 
            ||
| 673 |     { | 
            ||
| 674 |         $today = strtotime('today midnight'); | 
            ||
| 675 | |||
| 676 | // start time should not be in the future.  | 
            ||
| 677 | $startTime = min(  | 
            ||
| 678 | is_int($start) ? $start : strtotime((string)$start),  | 
            ||
| 679 | $today  | 
            ||
| 680 | );  | 
            ||
| 681 | |||
| 682 | // end time defaults to now, and will not be in the future.  | 
            ||
| 683 | $endTime = min(  | 
            ||
| 684 | (is_int($end) ? $end : strtotime((string)$end)) ?: $today,  | 
            ||
| 685 | $today  | 
            ||
| 686 | );  | 
            ||
| 687 | |||
| 688 | // Default to $this->defaultDays or $this->maxDays before end time if start is not present.  | 
            ||
| 689 | $daysOffset = $this->defaultDays ?? $this->maxDays;  | 
            ||
| 690 |         if (false === $startTime && is_int($daysOffset)) { | 
            ||
| 691 |             $startTime = strtotime("-$daysOffset days", $endTime); | 
            ||
| 692 | }  | 
            ||
| 693 | |||
| 694 | // Default to $this->defaultDays or $this->maxDays after start time if end is not present.  | 
            ||
| 695 |         if (false === $end && is_int($daysOffset)) { | 
            ||
| 696 | $endTime = min(  | 
            ||
| 697 |                 strtotime("+$daysOffset days", $startTime), | 
            ||
| 698 | $today  | 
            ||
| 699 | );  | 
            ||
| 700 | }  | 
            ||
| 701 | |||
| 702 | // Reverse if start date is after end date.  | 
            ||
| 703 |         if ($startTime > $endTime && false !== $startTime && false !== $end) { | 
            ||
| 704 | $newEndTime = $startTime;  | 
            ||
| 705 | $startTime = $endTime;  | 
            ||
| 706 | $endTime = $newEndTime;  | 
            ||
| 707 | }  | 
            ||
| 708 | |||
| 709 | // Finally, don't let the date range exceed $this->maxDays.  | 
            ||
| 710 |         $startObj = DateTime::createFromFormat('U', (string)$startTime); | 
            ||
| 711 |         $endObj = DateTime::createFromFormat('U', (string)$endTime); | 
            ||
| 712 |         if (is_int($this->maxDays) && $startObj->diff($endObj)->days > $this->maxDays) { | 
            ||
| 713 | // Show warnings that the date range was truncated.  | 
            ||
| 714 |             $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays]); | 
            ||
| 715 | |||
| 716 |             $startTime = strtotime("-$this->maxDays days", $endTime); | 
            ||
| 717 | }  | 
            ||
| 718 | |||
| 719 | return [$startTime, $endTime];  | 
            ||
| 720 | }  | 
            ||
| 721 | |||
| 722 | /**  | 
            ||
| 723 | * Given the params hash, normalize any legacy parameters to their modern equivalent.  | 
            ||
| 724 | * @param string[] $params  | 
            ||
| 725 | * @return string[]  | 
            ||
| 726 | */  | 
            ||
| 727 | private function convertLegacyParams(array $params): array  | 
            ||
| 767 | }  | 
            ||
| 768 | |||
| 769 | /************************  | 
            ||
| 770 | * FORMATTING RESPONSES *  | 
            ||
| 771 | ************************/  | 
            ||
| 772 | |||
| 773 | /**  | 
            ||
| 774 | * Get the rendered template for the requested format. This method also updates the cookies.  | 
            ||
| 775 | * @param string $templatePath Path to template without format,  | 
            ||
| 776 | * such as '/editCounter/latest_global'.  | 
            ||
| 777 | * @param array $ret Data that should be passed to the view.  | 
            ||
| 778 | * @return Response  | 
            ||
| 779 | * @codeCoverageIgnore  | 
            ||
| 780 | */  | 
            ||
| 781 | public function getFormattedResponse(string $templatePath, array $ret): Response  | 
            ||
| 782 |     { | 
            ||
| 783 |         $format = $this->request->query->get('format', 'html'); | 
            ||
| 784 |         if ('' == $format) { | 
            ||
| 785 | // The default above doesn't work when the 'format' parameter is blank.  | 
            ||
| 786 | $format = 'html';  | 
            ||
| 787 | }  | 
            ||
| 788 | |||
| 789 | // Merge in common default parameters, giving $ret (from the caller) the priority.  | 
            ||
| 790 | $ret = array_merge([  | 
            ||
| 791 | 'project' => $this->project,  | 
            ||
| 792 | 'user' => $this->user,  | 
            ||
| 793 | 'page' => $this->page,  | 
            ||
| 794 | 'namespace' => $this->namespace,  | 
            ||
| 795 | 'start' => $this->start,  | 
            ||
| 796 | 'end' => $this->end,  | 
            ||
| 797 | ], $ret);  | 
            ||
| 798 | |||
| 799 | $formatMap = [  | 
            ||
| 800 | 'wikitext' => 'text/plain',  | 
            ||
| 801 | 'csv' => 'text/csv',  | 
            ||
| 802 | 'tsv' => 'text/tab-separated-values',  | 
            ||
| 803 | 'json' => 'application/json',  | 
            ||
| 804 | ];  | 
            ||
| 805 | |||
| 806 | $response = new Response();  | 
            ||
| 807 | |||
| 808 | // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.  | 
            ||
| 809 | $this->setCookies($response);  | 
            ||
| 810 | |||
| 811 | // If requested format does not exist, assume HTML.  | 
            ||
| 812 |         if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) { | 
            ||
| 813 | $format = 'html';  | 
            ||
| 814 | }  | 
            ||
| 815 | |||
| 816 |         $response = $this->render("$templatePath.$format.twig", $ret, $response); | 
            ||
| 817 | |||
| 818 | $contentType = $formatMap[$format] ?? 'text/html';  | 
            ||
| 819 |         $response->headers->set('Content-Type', $contentType); | 
            ||
| 820 | |||
| 821 |         if (in_array($format, ['csv', 'tsv'])) { | 
            ||
| 822 | $filename = $this->getFilenameForRequest();  | 
            ||
| 823 | $response->headers->set(  | 
            ||
| 824 | 'Content-Disposition',  | 
            ||
| 825 |                 "attachment; filename=\"{$filename}.$format\"" | 
            ||
| 826 | );  | 
            ||
| 827 | }  | 
            ||
| 828 | |||
| 829 | return $response;  | 
            ||
| 830 | }  | 
            ||
| 831 | |||
| 832 | /**  | 
            ||
| 833 | * Returns given filename from the current Request, with problematic characters filtered out.  | 
            ||
| 834 | * @return string  | 
            ||
| 835 | */  | 
            ||
| 836 | private function getFilenameForRequest(): string  | 
            ||
| 837 |     { | 
            ||
| 838 | $filename = trim($this->request->getPathInfo(), '/');  | 
            ||
| 839 |         return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename)); | 
            ||
| 840 | }  | 
            ||
| 841 | |||
| 842 | /**  | 
            ||
| 843 | * Return a JsonResponse object pre-supplied with the requested params.  | 
            ||
| 844 | * @param array $data  | 
            ||
| 845 | * @return JsonResponse  | 
            ||
| 846 | */  | 
            ||
| 847 | public function getFormattedApiResponse(array $data): JsonResponse  | 
            ||
| 848 |     { | 
            ||
| 849 | $response = new JsonResponse();  | 
            ||
| 850 | $response->setEncodingOptions(JSON_NUMERIC_CHECK);  | 
            ||
| 851 | $response->setStatusCode(Response::HTTP_OK);  | 
            ||
| 852 | |||
| 853 | // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).  | 
            ||
| 854 |         if ($this->user && $this->user->isIpRange()) { | 
            ||
| 855 | $this->params['username'] = $this->user->getUsername();  | 
            ||
| 856 | }  | 
            ||
| 857 | |||
| 858 | $elapsedTime = round(  | 
            ||
| 859 |             microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'), | 
            ||
| 860 | 3  | 
            ||
| 861 | );  | 
            ||
| 862 | |||
| 863 | // Any pipe-separated values should be returned as an array.  | 
            ||
| 864 |         foreach ($this->params as $param => $value) { | 
            ||
| 865 |             if (is_string($value) && false !== strpos($value, '|')) { | 
            ||
| 866 |                 $this->params[$param] = explode('|', $value); | 
            ||
| 867 | }  | 
            ||
| 868 | }  | 
            ||
| 869 | |||
| 870 | $ret = array_merge($this->params, [  | 
            ||
| 871 | // In some controllers, $this->params['project'] may be overridden with a Project object.  | 
            ||
| 872 | 'project' => $this->project->getDomain(),  | 
            ||
| 873 | ], $data, ['elapsed_time' => $elapsedTime]);  | 
            ||
| 874 | |||
| 875 | // Merge in flash messages, putting them at the top.  | 
            ||
| 876 |         $flashes = $this->get('session')->getFlashBag()->peekAll(); | 
            ||
| 877 | $ret = array_merge($flashes, $ret);  | 
            ||
| 878 | |||
| 879 | // Flashes now can be cleared after merging into the response.  | 
            ||
| 880 |         $this->get('session')->getFlashBag()->clear(); | 
            ||
| 881 | |||
| 882 | $response->setData($ret);  | 
            ||
| 883 | |||
| 884 | return $response;  | 
            ||
| 885 | }  | 
            ||
| 886 | |||
| 887 | /*********  | 
            ||
| 888 | * OTHER *  | 
            ||
| 889 | *********/  | 
            ||
| 890 | |||
| 891 | /**  | 
            ||
| 892 | * Record usage of an API endpoint.  | 
            ||
| 893 | * @param string $endpoint  | 
            ||
| 894 | * @codeCoverageIgnore  | 
            ||
| 895 | */  | 
            ||
| 896 | public function recordApiUsage(string $endpoint): void  | 
            ||
| 922 | // Do nothing. API response should still be returned rather than erroring out.  | 
            ||
| 923 | }  | 
            ||
| 924 | }  | 
            ||
| 925 | |||
| 926 | /**  | 
            ||
| 927 | * Add a flash message.  | 
            ||
| 928 | * @param string $type  | 
            ||
| 929 | * @param string $key i18n key or raw message.  | 
            ||
| 930 | * @param array $vars  | 
            ||
| 931 | */  | 
            ||
| 932 | public function addFlashMessage(string $type, string $key, array $vars = []): void  | 
            ||
| 937 | );  | 
            ||
| 938 | }  | 
            ||
| 939 | }  | 
            ||
| 940 |