| 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 |