editoria11y-1.0.0-alpha8/src/Api.php
src/Api.php
<?php
namespace Drupal\editoria11y;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\StatementInterface;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Path\PathValidatorInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\editoria11y\Exception\Editoria11yApiException;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Handles reporting and dismissals.
*
* @phpstan-consistent-constructor
*/
class Api {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected AccountInterface $account;
/**
* The current database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected Connection $connection;
/**
* The manager property.
*/
protected EntityTypeManager $manager;
/**
* The configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected ConfigFactoryInterface $configFactory;
/**
* The path validator service.
*
* @var \Drupal\Core\Path\PathValidatorInterface
*/
protected PathValidatorInterface $pathValidator;
/**
* Constructs an Api object.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The current user.
* @param \Drupal\Core\Database\Connection $connection
* The database connection.
* @param \Drupal\Core\Entity\EntityTypeManager $manager
* The entity type manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The configuration factory.
* @param \Drupal\Core\Path\PathValidatorInterface $path_validator
* The path validator service.
*/
public function __construct(AccountInterface $account, Connection $connection, EntityTypeManager $manager, ConfigFactoryInterface $config_factory, PathValidatorInterface $path_validator) {
$this->account = $account;
$this->connection = $connection;
$this->manager = $manager;
$this->configFactory = $config_factory;
$this->pathValidator = $path_validator;
}
/**
* Creates an instance of the Api class.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* The service container.
*
* @return static
* A new instance of the Api class.
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('current_user'),
$container->get('database'),
$container->get('entity_type.manager'),
$container->get('config.factory'),
$container->get('path.validator')
);
}
/**
* Get the pid ID.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function getPage($path, $entity_type, $entity_id, $language, $findStale = FALSE): ?StatementInterface {
// Confirm result_names is array?
$this->validateNumber($entity_id);
$this->validatePath($path);
// Get back the page ID.
$query = $this->connection->select('editoria11y_pages');
$query->fields('editoria11y_pages', ['pid']);
if ($findStale) {
$query->condition('page_path', $path, '!=');
}
else {
$query->condition('page_path', $path);
}
$query->condition('entity_id', $entity_id);
$query->condition('entity_type', $entity_type);
$query->condition('page_language', $language);
return $query->execute();
}
/**
* Get the existing results and pid ID.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function getPageData($path, $type, $id, $language): ?StatementInterface {
$this->validateNumber($id);
$this->validatePath($path);
// Get back the page ID.
$query = $this->connection->select('editoria11y_pages', 'p');
$query->fields('p', ['pid']);
$query->condition('p.page_path', $path);
$query->condition('p.entity_id', $id);
$query->condition('p.entity_type', $type);
$query->condition('p.page_language', $language);
$query->leftJoin('editoria11y_results', 'r', 'p.pid = r.pid');
$query->leftJoin('editoria11y_dismissals', 'd', 'p.pid = d.pid');
$query->fields('r', ['result_key']);
$query->fields('d', ['result_key']);
$query->fields('d', ['element_id']);
return $query->execute();
}
/**
* Set and return an pid ID.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function updatePage($results, $pid, $now): void {
// Confirm result_names is array?
$this->validateNotNull($results["page_title"]);
// @todo 3.0 can we multi-write?
$update = $this->connection->update("editoria11y_pages");
// Track the type and count of issues detected on this page.
// Update the "last seen" date of the page.
$update->fields(
[
'page_title' => $results["page_title"],
'page_result_count' => $results["page_count"],
'entity_type' => $results["entity_type"],
'route_name' => $results["route_name"],
'updated' => $now,
]
);
$update->condition('pid', $pid);
$update->execute();
}
/**
* Set and return an pid ID.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Validation errors.
* @throws \Exception
* Invalid data.
*/
public function insertPage($results, $now): int|string {
// Confirm result_names is array?
$this->validateNotNull($results["page_title"]);
$this->validateNumber($results["page_count"]);
$this->validateNumber($results["entity_id"]);
$this->validatePath($results["page_path"]);
// @todo 3.0 can we multi-write?
$insert = $this->connection->insert("editoria11y_pages");
// Track the type and count of issues detected on this page.
$insert->fields(
[
'page_title' => $results["page_title"],
'page_path' => $results["page_path"],
'entity_id' => $results["entity_id"],
'page_language' => $results["language"],
'page_result_count' => $results["page_count"],
'entity_type' => $results["entity_type"],
'route_name' => $results["route_name"],
'updated' => $now,
]
);
// Get back the page ID.
return $insert->execute();
}
/**
* Function to test the results.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function testResults($results): void {
$now = time();
// Get or create EID, and return any existing results or dismissals.
$page_data = $this->getPageData($results["page_path"], $results["entity_type"], $results["entity_id"], $results["language"]);
// $page_data = $this->setPage($results, $now);
// Stash existing information to reduce DB write-backs later.
$old_results = [];
$new_results = [];
$old_dismissals = FALSE;
$pid = FALSE;
foreach ($page_data as $result) {
if (!$pid) {
$pid = $result->pid;
}
if (!empty($result->result_key)) {
$old_results[] = $result->result_key;
}
if (!empty($result->d_result_key)) {
$old_dismissals = TRUE;
}
}
if (!$pid) {
// There was no page at this address. Make a new one.
if (count($results["results"]) > 0 || count($results["oks"]) > 0) {
$pid = $this->insertPage($results, $now);
}
}
else {
$this->updatePage($results, $pid, $now);
}
// Remove old results at this route.
// Should we move this to the dashboard?
// @todo 3.0 not yet tested
$incorrectData = $this->getPage($results["page_path"], $results["entity_type"], $results["entity_id"], $results["language"], TRUE);
$incorrectPage = $incorrectData->fetchField();
if ($incorrectPage) {
$this->purgePage($incorrectPage, $results["page_path"]);
}
if (!$pid) {
// Nothing to report, nothing to remove.
return;
}
// Update last seen.
if ($results["page_count"] > 0) {
foreach ($results["results"] as $key => $value) {
$this->validateNumber($value['count']);
// @todo 3.0: we need to handle page parameters that change content
$new_results[] = $key;
$this->validateNotNull($key);
$this->validateNotNull($value['result_name']);
$updatePage = $this->connection->merge("editoria11y_results");
$updatePage->insertFields(
[
'result_name_count' => $value['count'],
'result_name' => $value['result_name'],
'result_key' => $key,
'created' => $now,
]
);
$updatePage->updateFields(
[
'result_name_count' => $value['count'],
'result_name' => $value['result_name'],
]
);
$updatePage->keys(
[
'pid' => $pid,
'result_key' => $key,
]
);
$updatePage->execute();
}
}
elseif (!$old_dismissals) {
// Drop page with no remaining records.
$this->purgePage($pid, $results['page_path']);
}
// Remove results no longer in the result set.
$stale_results = array_diff($old_results, $new_results);
foreach ($stale_results as $result) {
$this->connection->delete('editoria11y_results')
->condition('result_key', $result)
->condition('pid', $pid)
->execute();
}
// Update stale dates.
// Marked-as-ok issues are not in the main results foreach.
// Note: v2.1.0 added entity_id; old entries may be missing it.
// Note: v2.2.10 changed entity type; old entries have a different format.
if ($old_dismissals) {
foreach ($results["oks"] as $ok) {
if (!in_array($ok["resultKey"], $new_results)) {
$new_results[] = $ok["resultKey"];
}
}
if (count($new_results) > 0) {
$this->connection->update('editoria11y_dismissals')
->fields(['stale_date' => NULL])
->condition('result_key', $new_results, 'IN')
->condition('pid', $pid)
->execute();
// Set stale records if the alert disappeared.
$this->connection->update('editoria11y_dismissals')
->fields(['stale_date' => $now])
->condition('result_key', $new_results, 'NOT IN')
->condition('pid', $pid)
->isNull('stale_date')
->execute();
}
else {
// If there are no new results, mark all old dismissals as stale.
$this->connection->update('editoria11y_dismissals')
->fields(['stale_date' => $now])
->condition('pid', $pid)
->isNull('stale_date')
->execute();
}
}
}
/**
* The Purge page function.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function purgePage($page = FALSE, $path = FALSE): void {
// Internal functions provide path, direct calls do not.
if ($page) {
$this->validateNotNull($page);
$path = $this->connection->select("editoria11y_pages")
->fields('editoria11y_pages', ['pid'])
->condition('pid', $page)
->execute()->fetchField();
}
elseif ($path) {
$this->validateNotNull($path);
// Get back the page ID.
$query = $this->connection->select('editoria11y_pages');
$query->fields('editoria11y_pages', ['pid']);
$query->condition('page_path', $path);
$page = $query->execute()->fetchField();
}
if ($page && $path) {
$this->connection->delete("editoria11y_dismissals")
->condition('pid', $page)
->execute();
$this->connection->delete("editoria11y_results")
->condition('pid', $page)
->execute();
$this->connection->delete("editoria11y_pages")
->condition('pid', $page)
->execute();
// Clear cache for the referring page and dashboard.
$invalidate = preg_replace('/[^a-zA-Z0-9]/', '', $path, -80);
Cache::invalidateTags(
['editoria11y:dismissals_' . $invalidate]
);
}
}
/**
* The purge dismissal function.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function purgeDismissal($data): void {
$page_path = FALSE;
if ($data['dismissal_id']) {
$this->validateNumber($data['dismissal_id']);
$this->validateNumber($data['pid']);
$page_path = $this->connection->select('editoria11y_pages')
->fields('editoria11y_pages', ['page_path'])
->condition('pid', $data['pid'])
->execute()->fetchField();
if (!empty($page_path)) {
// This deletes ALL dismissals of this type on the page.
$this->connection->delete("editoria11y_dismissals")
->condition('pid', $data['pid'])
->condition('id', $data["dismissal_id"])
->execute();
}
}
elseif ($data['page_path'] && $data['uid']) {
$this->validateNumber($data['pid']);
$this->validateNotNull($data['entity_type']);
$this->validateNotNull($data['entity_id']);
$this->validateNotNull($data['language']);
$page = $this->getPage($data['page_path'], $data['entity_type'], $data['entity_id'], $data['language'])->fetchField();
if ($page) {
$this->connection->delete("editoria11y_dismissals")
->condition('pid', $data['pid'])
->condition('element_id', $data['element_id'])
->condition('result_key', $data["result_key"])
->execute();
$page_path = $data['page_path'];
}
// Clear cache for the referring page.
if (!empty($page_path)) {
// This deletes ALL dismissals of this type on the page.
$invalidate = preg_replace('/[^a-zA-Z0-9]/', '', substr($data["page_path"], -80));
Cache::invalidateTags(
['editoria11y:dismissals_' . $invalidate]
);
}
}
}
/**
* The dismiss function.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
public function dismiss($dismissal): void {
$this->validatePath($dismissal["page_path"]);
$now = time();
$get_page = $this->getPageData($dismissal['page_path'], $dismissal['entity_type'], $dismissal['entity_id'], $dismissal["language"],);
$pid = $get_page->fetchField();
if (!$pid) {
$dismissal["page_count"] = 0;
// Theoretically a dynamic update could send a dismissal as a new record.
$pid = $this->insertPage($dismissal, $now);
}
$this->validateNumber($pid);
foreach ($dismissal['dismissals'] as $item) {
$operation = $item['dismissal_status'];
$this->validateDismissalType($operation);
$this->validateDismissalPermission($operation);
if ($operation == "reset") {
if ($this->account->hasPermission('mark as ok in editoria11y')) {
// Reset all for this result.
$this->connection->delete("editoria11y_dismissals")
->condition('pid', $pid)
->condition('result_key', $item["result_key"])
->condition('element_id', $item['element_id'])
->condition('dismissal_status', "ok")
->execute();
}
if ($this->account->hasPermission('mark as hidden in editoria11y')) {
$this->connection->delete("editoria11y_dismissals")
->condition('pid', $pid)
->condition('dismissal_status', "hide")
->condition('uid', $this->account->id())
->condition('element_id', $item['element_id'])
->condition('result_key', $item["result_key"])
->execute();
}
}
else {
$this->validateNotNull($item["result_name"]);
$this->validateNotNull($item["result_key"]);
$this->connection->merge("editoria11y_dismissals")
->insertFields(
[
'pid' => $pid,
'uid' => $this->account->id(),
'element_id' => $item["element_id"],
'result_name' => $item["result_name"],
'result_key' => $item["result_key"],
'dismissal_status' => $operation,
'created' => $now,
]
)
->updateFields(
[
'uid' => $this->account->id(),
'element_id' => $item["element_id"],
'result_name' => $item["result_name"],
'result_key' => $item["result_key"],
'dismissal_status' => $operation,
]
)
->keys(
[
'pid' => $pid,
'result_key' => $item["result_key"],
'uid' => $this->account->id(),
'element_id' => $item["element_id"],
]
)
->execute();
}
}
// Clear cache for the referring page and dashboard.
$invalidate = preg_replace('/[^a-zA-Z0-9]/', '', substr($dismissal["page_path"], -80));
Cache::invalidateTags(
['editoria11y:dismissals_' . $invalidate]
);
}
/**
* Remove deleted pages.
*/
public function purgeDeletedRecords(): void {
// @todo 3.0;
// Delete nodes with invalid NIDS.
$query = $this->connection->select('editoria11y_pages');
// Delete terms with invalid TIDS.
// Delete users with invalid UIDS.
}
/**
* Throw exception for missing values.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
private function validateNotNull($user_input): void {
if (empty($user_input)) {
throw new Editoria11yApiException("Missing value");
}
}
/**
* Throw exception if user does not have permission for this dismissal type.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid role.
*/
private function validateDismissalPermission($operation): void {
$canHide = $this->account->hasPermission('mark as hidden in editoria11y');
$canOk = $this->account->hasPermission('mark as ok in editoria11y');
if (
(!($canHide || $canOk) ||
(!$canHide && $operation === 'hide') ||
(!$canOk && $operation === 'ok')
)
) {
throw new Editoria11yApiException("Not allowed");
}
}
/**
* This function is used to validate the requested path.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
private function validatePath($user_input): void {
$config = $this->configFactory->get('editoria11y.settings');
$prefix = $config->get('redundant_prefix');
if (!empty($prefix) && strlen($prefix) < strlen($user_input) && str_starts_with($user_input, $prefix)) {
// Replace ignorable subfolders.
$altPath = substr_replace($user_input, "", 0, strlen($prefix));
if (
!(
$this->pathValidator->getUrlIfValid($altPath) ||
$this->pathValidator->getUrlIfValid($user_input)
)
) {
throw new Editoria11yApiException('Invalid page path on API report: "' . $user_input . '". If site is installed in subfolder, check Editoria11y config item "Syncing results to reports
--> Remove redundant base url from URLs"');
}
}
else {
if (!$this->pathValidator->getUrlIfValid($user_input)) {
throw new Editoria11yApiException('Invalid page path on API report: "' . $user_input . '". If site is installed in subfolder, check Editoria11y config item "Syncing results to reports
--> Remove redundant base url from URLs"');
}
}
}
/**
* Validate dismissal status function.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
private function validateDismissalType($user_input): void {
if (!($user_input === 'ok' || $user_input === 'hide' || $user_input === 'reset')) {
throw new Editoria11yApiException("Invalid dismissal operation: $user_input");
}
}
/**
* Validate number function.
*
* @throws \Drupal\editoria11y\Exception\Editoria11yApiException
* Invalid data.
*/
private function validateNumber($user_input): void {
if (!(is_numeric($user_input))) {
throw new Editoria11yApiException("Nan: $user_input");
}
}
}
