Perform periodic fixity checks on selected files.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

402 lines
11 KiB

<?php
namespace Drupal\dgi_fixity;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\dgi_fixity\Entity\FixityCheck;
use Drupal\dgi_fixity\Form\SettingsForm;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\filehash\FileHash;
use Drupal\media\MediaInterface;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
use Psr\Log\LoggerInterface;
/**
* Decorates the FileHash services adding additional functionality.
*/
class FixityCheckService implements FixityCheckServiceInterface {
use StringTranslationTrait;
/**
* Config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $config;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* A date time instance.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The mail manager service.
*
* @var \Drupal\Core\Mail\MailManagerInterface
*/
protected $mailManager;
/**
* The logger for this service.
*
* @var Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The service to decorate.
*
* @var \Drupal\filehash\FileHash
*/
protected $filehash;
/**
* Constructor.
*/
public function __construct(TranslationInterface $string_translation, ConfigFactoryInterface $config, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time, MailManagerInterface $mail_manager, LoggerInterface $logger, FileHash $filehash) {
$this->stringTranslation = $string_translation;
$this->config = $config;
$this->entityTypeManager = $entity_type_manager;
$this->time = $time;
$this->mailManager = $mail_manager;
$this->logger = $logger;
$this->filehash = $filehash;
}
/**
* {@inheritdoc}
*/
public function fromEntityTypes(): array {
return [
'media',
'file',
];
}
/**
* {@inheritdoc}
*/
public function fromEntity(EntityInterface $entity): ?FixityCheckInterface {
$entity_type_id = $entity->getEntityTypeId();
switch ($entity_type_id) {
case 'media':
/** @var \Drupal\media\MediaInterface $entity */
return $this->fromMedia($entity);
case 'file':
/** @var \Drupal\file\FileInterface $entity */
return $this->fromFile($entity);
default:
throw new \InvalidArgumentException("Cannot convert {$entity_type_id} to fixity_check.");
}
}
/**
* {@inheritdoc}
*/
public function fromFile($file): ?FixityCheckInterface {
$fid = $file instanceof FileInterface ? $file->id() : (int) $file;
// It is only possible to have a single fixity_check entity per-file.
$results = $this->entityTypeManager->getStorage('fixity_check')->loadByProperties(['file' => $fid]);
if (count($results) === 1) {
/** @var \Drupal\dgi_fixity\FixityCheckInterface $fixity_check */
$fixity_check = reset($results);
}
else {
$fixity_check = FixityCheck::create([
'file' => $fid,
]);
}
return $fixity_check;
}
/**
* {@inheritdoc}
*/
public function fromMedia(MediaInterface $media): ?FixityCheckInterface {
$fid = $media->getSource()->getSourceFieldValue($media);
return $this->fromFile($fid);
}
/**
* {@inheritdoc}
*/
public function threshold(): int {
$threshold = &drupal_static(__FUNCTION__);
if (is_null($threshold)) {
$settings = $this->config->get(SettingsForm::CONFIG_NAME);
$threshold = strtotime($settings->get(SettingsForm::THRESHOLD), $this->time->getRequestTime());
}
return $threshold;
}
/**
* {@inheritdoc}
*/
public function scheduled(FixityCheckInterface $check): ?int {
if ($check->getPeriodic()) {
$now = time();
if ($check->wasPerformed()) {
$diff = $now - $this->threshold();
return $check->getPerformed() + $diff;
}
// Never performed, can be performed immediately.
return $now;
}
// Not periodic therefore not scheduled.
return NULL;
}
/**
* {@inheritdoc}
*/
public function source(string $source, int $limit): ?ViewExecutable {
// Only process those which have not already enabled periodic checks.
[$view_id, $display_id] = explode(':', $source);
$view = Views::getView($view_id);
if ($view) {
$view->setDisplay($display_id);
$view->getDisplay()->setOption('entity_reference_options', ['limit' => $limit]);
$view->addHandler($display_id, 'relationship', 'file_managed', 'reverse_file_fixity_check');
$view->addHandler(
$display_id, 'filter', 'fixity_check', 'periodic',
['relationship' => 'reverse_file_fixity_check', 'value' => 0],
'periodic'
);
$view->addHandler(
$display_id, 'field', 'fixity_check', 'periodic',
['relationship' => 'reverse_file_fixity_check'],
'periodic'
);
}
return $view;
}
/**
* {@inheritdoc}
*/
public function check(File $file, bool $force = FALSE) {
/** @var \Drupal\dgi_fixity\Entity\FixityCheckInterface[] $existing_checks */
$existing_checks = $this->entityTypeManager->getStorage('fixity_check')->loadByProperties(['file' => $file->id()]);
if (empty($existing_checks)) {
$check = FixityCheck::create()->setFile($file);
}
else {
// Should only ever be at most one due to the UniqueFieldEntityReference
// constraint on the file field.
$check = reset($existing_checks);
// Do not perform if the threshold for time since the last check has not
// been exceeded.
if (!$force) {
if ($check->getPerformed() > $this->threshold()) {
return NULL;
}
}
// Trigger a new revision (clears the performed / state fields).
// If the check has never been performed before do not modify the
// existing version.
if ($check->wasPerformed()) {
$check->setNewRevision();
}
}
$uri = $file->getFileUri();
// Assume success until proven untrue.
$state = FixityCheck::STATE_MATCHES;
// If column is set, only generate that hash.
foreach ($this->filehash->algos() as $column => $algo) {
// Nothing to do if the previous checksum value is not known.
if (!isset($file->{$column})) {
$state = FixityCheck::STATE_NO_CHECKSUM;
break;
}
// Nothing to do if file URI is empty.
if (NULL === $uri || '' === $uri || !file_exists($uri)) {
$state = FixityCheck::STATE_MISSING;
break;
}
// Unreadable files will have NULL hash values.
elseif (preg_match('/^blake2b_([0-9]{3})$/', $algo, $matches)) {
$hash = $this->filehash->blake2b($uri, $matches[1] / 8) ?: NULL;
}
else {
$hash = hash_file($algo, $uri) ?: NULL;
}
if ($hash === NULL) {
$state = FixityCheck::STATE_GENERATION_FAILED;
break;
}
if ($file->{$column}->value !== $hash) {
$state = FixityCheck::STATE_MISMATCHES;
break;
}
}
$check->setState($state);
$check->setPerformed($this->time->getRequestTime());
$check->setQueued(0);
$check->save();
// Log results.
$message = '@entity-type %label: %state';
$args = [
'@entity-type' => $check->getEntityType()->getSingularLabel(),
'%label' => $check->label(),
'%state' => $check->getStateProperty($check->getState(), 'label'),
'link' => Link::createFromRoute(
$this->t('View'),
'entity.fixity_check.revision',
[
'fixity_check' => $check->id(),
'fixity_check_revision' => $check->getRevisionId(),
],
)->toString(),
];
if ($check->passed()) {
$this->logger->info($message, $args);
}
else {
$this->logger->error($message, $args);
}
return $check;
}
/**
* {@inheritdoc}
*/
public function stats(): array {
$storage = $this->entityTypeManager->getStorage('fixity_check');
// Group all current checks by their state.
// Ignore those that have not been performed yet.
$results = $storage->getAggregateQuery('AND')
->condition('performed', 0, '!=')
->groupBy('state')
->aggregate('id', 'COUNT')
->execute();
$failed = 0;
$states = [];
foreach ($results as $result) {
$state = $result['state'];
$count = $result['id_count'];
$states[$state] = $count;
// If there are any checks which have not 'passed', the aggregate state
// of all checks is failure.
if (FixityCheck::getStateProperty($state, 'passed') === FALSE) {
$failed += $count;
}
}
// All active checks.
$periodic = (int) $storage->getQuery('AND')
->count('id')
->condition('periodic', TRUE)
->execute();
// All checks performed ever.
$revisions = (int) $storage->getQuery('AND')
->allRevisions()
->count('id')
->execute();
// Checks which have exceeded the threshold and should be performed again.
$threshold = $this->threshold();
$current = (int) $storage->getQuery('AND')
->condition('periodic', TRUE)
->condition('performed', $threshold, '>=')
->count('id')
->execute();
// Up to date checks.
$expired = $periodic - $current;
return [
'periodic' => [
'total' => $periodic,
'current' => $current,
'expired' => $expired,
],
'revisions' => $revisions,
'states' => $states,
'failed' => $failed,
];
}
/**
* {@inheritdoc}
*/
public function summary(array $stats, array $options = []): array {
$summary = [];
$summary[] = $this->formatPlural(
$stats['revisions'],
'@count check has been performed since tracking started.',
'@count checks have been performed since tracking started.',
[],
$options
);
$summary[] = $this->formatPlural(
$stats['periodic']['total'],
'@count file is set to be checked periodically.',
'@count files are set to be checked periodically.',
[],
$options
);
$summary[] = $this->formatPlural(
$stats['periodic']['current'],
'@count periodic check is up to date.',
'@count periodic checks are up to date.',
[],
$options
);
if ($stats['periodic']['expired'] > 0) {
$summary[] = $this->formatPlural(
$stats['periodic']['expired'],
'@count periodic check is out to date.',
'@count periodic checks are out to date.',
[],
$options
);
}
if ($stats['failed'] > 0) {
$summary[] = $this->formatPlural(
$stats['failed'],
'@count check has failed.',
'@count checks have failed.',
[],
$options
);
foreach ($stats['states'] as $state => $count) {
$summary[] = $this->formatPlural(
$count,
FixityCheck::getStateProperty($state, 'singular'),
FixityCheck::getStateProperty($state, 'plural'),
[],
$options
);
}
}
return $summary;
}
}