diff --git a/README.md b/README.md
index 691c67b..08140e5 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,110 @@
-# DGI Fixity
+# Fixity
## Introduction
-A module to perform fixity checks on original media.
+Perform periodic fixity checks on selected files.
+
+This module defines a new content entity type `fixity_check`. This entity is
+used as an audit trail for fixity checks performed on a related `file` entity.
+Wherein the revisions of the `fixity_check` record the results of previous
+checks against that `file` entity.
+
+This modules requires and enforces the following constraints on `fixity_check`
+entities:
+
+- **Must** be related to a `file`
+- `file` relations **must** be unique
+- `file` relation **cannot** be changed after creation
+- `performed` and `state` properties **cannot** be modified after creation.
+
+Users with the permission `Administer Fixity Checks` can:
+
+- Manually perform checks
+- Manually remove `fixity_check` entities and their revisions
+- Manually mark files as requiring periodic checks
+- Generate `fixity_check` entities for all previously existing files
+
+Users with the permission `View Fixity Checks` can:
+
+- View fixity audit log of Media entities
+
+A `cron` hook is setup to automatically mark files as _requiring_ periodic
+checks. As well as performing those checks on a regular basis. Email
+notifications can be configured to alert the selected user of the status
+of all performed checks on a regular basis or only when an error occurs.
## Requirements
This module requires the following modules/libraries:
-* [filehash](https://www.drupal.org/project/filehash)
+* [filehash]
+
+## Configuration
+
+The module can be configured at `admin/config/fixity`.
-## Usage
+## Drush
+A number of drush commands come bundled with this module.
+
+```bash
+$ drush dgi_fixity:clear --help
+Sets the periodic check flag to FALSE for all files.
+```
+
+```bash
+$ drush dgi_fixity:generate --help
+Creates a fixity_check entity for all previously created files.
+```
+
+```bash
+$ drush dgi_fixity:check --help
+Perform fixity checks on files.
+
+Options:
+ --fids[=FIDS] Comma separated list of file identifiers, or a path to a file containing file identifiers.
+ The file should have each fid separated by a new line. If not specified the modules settings
+ for sources is used to determine which files to check.
+ --force Skip time elapsed threshold check when processing files.
+```
## Installation
-Install as usual, see
-[this](https://drupal.org/documentation/install/modules-themes/modules-8) for
-further information.
+Install as usual, see [this][install] for further information.
+
+Additionally after this module is first enabled, you will need to generate
+`fixity_check` entities for all pre-existing `file` entities. This does not
+require that the checks are performed, only that one `fixity_check` entity
+exists for every applicable `file` entity in the system.
+
+This can be done with `drush`:
+
+```bash
+drush dgi_fixity:generate
+```
+
+Or via the admin form on the page `admin/config/fixity/generate`.
## Troubleshooting/Issues
-Having problems or solved a problem? Contact
-[discoverygarden](http://support.discoverygarden.ca).
+Having problems or solved a problem? Contact [discoverygarden].
## Maintainers/Sponsors
Current maintainers:
-* [discoverygarden](http://www.discoverygarden.ca)
+* [discoverygarden]
## Development
If you would like to contribute to this module create an issue, pull request
-and or contact
-[discoverygarden](http://support.discoverygarden.ca).
+and or contact [discoverygarden].
## License
-[GPLv2](http://www.gnu.org/licenses/gpl-2.0.txt)
\ No newline at end of file
+[GPLv2][gplv2]
+
+[discoverygarden]: http://support.discoverygarden.ca
+[filehash]: https://www.drupal.org/project/filehash
+[gplv2]: http://www.gnu.org/licenses/gpl-2.0.txt
+[install]: https://drupal.org/documentation/install/modules-themes/modules-8
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..7639c05
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,8 @@
+{
+ "name": "discoverygarden/dgi_fixity",
+ "type": "drupal-module",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "drupal/filehash": "^2.0"
+ }
+}
diff --git a/config/install/dgi_fixity.settings.yml b/config/install/dgi_fixity.settings.yml
new file mode 100644
index 0000000..d4448f0
--- /dev/null
+++ b/config/install/dgi_fixity.settings.yml
@@ -0,0 +1,7 @@
+sources:
+ 'fixity_check_source:all_files': 'fixity_check_source:all_files'
+threshold: '-1 month'
+batch_size: 100
+notify_user: 1
+notify_user_threshold: '-1 week'
+notify_status: 2
diff --git a/config/install/views.view.fixity_check_source.yml b/config/install/views.view.fixity_check_source.yml
new file mode 100644
index 0000000..fa357ec
--- /dev/null
+++ b/config/install/views.view.fixity_check_source.yml
@@ -0,0 +1,246 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ module:
+ - dgi_fixity
+ module:
+ - file
+id: fixity_check_source
+label: 'Fixity Check Source Default'
+module: views
+description: 'Default fixity check source, selects all permanent files.'
+tag: fixity
+base_table: file_managed
+base_field: fid
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Default
+ position: 0
+ display_options:
+ access:
+ type: none
+ options: { }
+ cache:
+ type: none
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: mini
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: ‹‹
+ next: ››
+ style:
+ type: default
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ uses_fields: false
+ row:
+ type: fields
+ options:
+ inline: { }
+ separator: ''
+ hide_empty: false
+ default_field_elements: true
+ fields:
+ fid:
+ id: fid
+ table: file_managed
+ field: fid
+ relationship: none
+ group_type: group
+ admin_label: 'File ID'
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: false
+ empty: ''
+ hide_empty: true
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_unformatted
+ settings: { }
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: file
+ entity_field: fid
+ plugin_id: field
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ sorts:
+ fid:
+ id: fid
+ table: file_managed
+ field: fid
+ relationship: none
+ group_type: group
+ admin_label: 'File ID'
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ entity_type: file
+ entity_field: fid
+ plugin_id: standard
+ header: { }
+ footer: { }
+ empty: { }
+ relationships: { }
+ arguments: { }
+ display_extenders: { }
+ show_admin_links: false
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ tags: { }
+ all_files:
+ display_plugin: entity_reference
+ id: all_files
+ display_title: 'All Files'
+ position: 1
+ display_options:
+ display_extenders: { }
+ style:
+ type: entity_reference
+ options:
+ search_fields:
+ fid: fid
+ row:
+ type: entity_reference
+ options:
+ default_field_elements: false
+ inline: { }
+ separator: '-'
+ hide_empty: true
+ display_description: 'Gets all files regardless of state or usage'
+ pager:
+ type: some
+ options:
+ items_per_page: 10
+ offset: 0
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ tags: { }
diff --git a/config/install/views.view.fixity_check_status.yml b/config/install/views.view.fixity_check_status.yml
new file mode 100644
index 0000000..6fa711d
--- /dev/null
+++ b/config/install/views.view.fixity_check_status.yml
@@ -0,0 +1,824 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+ - file
+ - user
+id: fixity_check_status
+label: 'Fixity Check Status'
+module: views
+description: 'Find and manage fixity checks'
+tag: fixity
+base_table: fixity_check
+base_field: id
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Default
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'administer fixity checks'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Filter
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: mini
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: ‹‹
+ next: ››
+ style:
+ type: table
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ override: true
+ sticky: false
+ caption: ''
+ summary: ''
+ description: ''
+ columns:
+ file: file
+ state: state
+ performed: performed
+ operations: operations
+ info:
+ file:
+ sortable: false
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ state:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ performed:
+ sortable: true
+ default_sort_order: desc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ operations:
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ default: performed
+ empty_table: false
+ row:
+ type: fields
+ fields:
+ fixity_check_bulk_form:
+ id: fixity_check_bulk_form
+ table: fixity_check
+ field: fixity_check_bulk_form
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ action_title: Action
+ include_exclude: exclude
+ selected_actions: { }
+ entity_type: fixity_check
+ plugin_id: bulk_form
+ view_fixity_check:
+ id: view_fixity_check
+ table: fixity_check
+ field: view_fixity_check
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: true
+ alter:
+ alter_text: false
+ text: '{{ file_1 }}'
+ make_link: true
+ path: '{{ view_fixity_check }}'
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_label
+ settings:
+ link: false
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: fixity_check
+ entity_field: file
+ plugin_id: field
+ state:
+ id: state
+ table: fixity_check
+ field: state
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: State
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: dgi_fixity_state
+ settings: { }
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: fixity_check
+ entity_field: state
+ plugin_id: field
+ performed:
+ id: performed
+ table: fixity_check
+ field: performed
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: Performed
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: timestamp_ago
+ settings:
+ future_format: '@interval hence'
+ past_format: '@interval ago'
+ granularity: 2
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: fixity_check
+ entity_field: performed
+ plugin_id: field
+ periodic:
+ id: periodic
+ table: fixity_check
+ field: periodic
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: Periodic
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: boolean
+ settings:
+ format: default
+ format_custom_true: ''
+ format_custom_false: ''
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: fixity_check
+ entity_field: periodic
+ plugin_id: field
+ operations:
+ table: fixity_check
+ field: operations
+ id: operations
+ entity_type: null
+ entity_field: null
+ plugin_id: entity_operations
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: 'Operations links'
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: true
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ destination: false
+ filters:
+ filename:
+ id: filename
+ table: file_managed
+ field: filename
+ relationship: file
+ group_type: group
+ admin_label: ''
+ operator: contains
+ value: ''
+ group: 1
+ exposed: true
+ expose:
+ operator_id: filename_op
+ label: Filename
+ description: ''
+ use_operator: false
+ operator: filename_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: filename
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ anonymous: '0'
+ administrator: '0'
+ placeholder: ''
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: filename
+ plugin_id: string
+ state:
+ id: state
+ table: fixity_check
+ field: state
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: '='
+ value:
+ min: ''
+ max: ''
+ value: ''
+ group: 1
+ exposed: true
+ expose:
+ operator_id: state_op
+ label: State
+ description: ''
+ use_operator: false
+ operator: state_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: state
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ anonymous: '0'
+ administrator: '0'
+ fedoraadmin: '0'
+ placeholder: ''
+ min_placeholder: ''
+ max_placeholder: ''
+ is_grouped: true
+ group_info:
+ label: State
+ description: ''
+ identifier: state
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items:
+ 1:
+ title: Passed
+ operator: '='
+ value:
+ value: '1'
+ min: ''
+ max: ''
+ 2:
+ title: Failed
+ operator: '!='
+ value:
+ value: '1'
+ min: ''
+ max: ''
+ entity_type: fixity_check
+ entity_field: state
+ plugin_id: numeric
+ performed:
+ id: performed
+ table: fixity_check
+ field: performed
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: '='
+ value:
+ min: ''
+ max: ''
+ value: '1970-01-01 00:00:00'
+ type: date
+ group: 1
+ exposed: true
+ expose:
+ operator_id: performed_op
+ label: Performed
+ description: ''
+ use_operator: false
+ operator: performed_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: performed
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ anonymous: '0'
+ administrator: '0'
+ fedoraadmin: '0'
+ placeholder: ''
+ min_placeholder: ''
+ max_placeholder: ''
+ is_grouped: true
+ group_info:
+ label: Performed
+ description: ''
+ identifier: performed
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items:
+ 1:
+ title: 'True'
+ operator: '!='
+ value:
+ type: date
+ value: '1970-01-01 00:00:00'
+ min: ''
+ max: ''
+ 2:
+ title: 'False'
+ operator: '='
+ value:
+ type: date
+ value: '1970-01-01 00:00:00'
+ min: ''
+ max: ''
+ entity_type: fixity_check
+ entity_field: performed
+ plugin_id: date
+ periodic:
+ id: periodic
+ table: fixity_check
+ field: periodic
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: '='
+ value: All
+ group: 1
+ exposed: true
+ expose:
+ operator_id: ''
+ label: Periodic
+ description: ''
+ use_operator: false
+ operator: periodic_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: periodic
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ anonymous: '0'
+ administrator: '0'
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: fixity_check
+ entity_field: periodic
+ plugin_id: boolean
+ sorts:
+ performed:
+ id: performed
+ table: fixity_check
+ field: performed
+ relationship: none
+ group_type: group
+ admin_label: ''
+ order: DESC
+ exposed: false
+ expose:
+ label: Performed
+ granularity: second
+ entity_type: fixity_check
+ entity_field: performed
+ plugin_id: date
+ title: Status
+ header: { }
+ footer: { }
+ empty:
+ area_text_custom:
+ id: area_text_custom
+ table: views
+ field: area_text_custom
+ relationship: none
+ group_type: group
+ admin_label: ''
+ empty: true
+ tokenize: false
+ content: 'No fixity checks have been performed.'
+ plugin_id: text_custom
+ relationships:
+ file:
+ id: file
+ table: fixity_check
+ field: file
+ relationship: none
+ group_type: group
+ admin_label: File
+ required: false
+ entity_type: fixity_check
+ entity_field: file
+ plugin_id: standard
+ arguments: { }
+ display_extenders: { }
+ filter_groups:
+ operator: AND
+ groups:
+ 1: AND
+ cache_metadata:
+ max-age: 0
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url
+ - url.query_args
+ - user.permissions
+ tags: { }
+ fixity_checks:
+ display_plugin: page
+ id: fixity_checks
+ display_title: Page
+ position: 1
+ display_options:
+ display_extenders: { }
+ path: admin/reports/fixity
+ enabled: true
+ cache_metadata:
+ max-age: 0
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url
+ - url.query_args
+ - user.permissions
+ tags: { }
diff --git a/config/optional/system.action.fixity_check_check_action.yml b/config/optional/system.action.fixity_check_check_action.yml
new file mode 100644
index 0000000..754635c
--- /dev/null
+++ b/config/optional/system.action.fixity_check_check_action.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+id: fixity_check_check_action
+label: 'Perform check'
+type: fixity_check
+plugin: dgi_fixity:check_action:fixity_check
+configuration: { }
diff --git a/config/optional/system.action.fixity_check_delete_action.yml b/config/optional/system.action.fixity_check_delete_action.yml
new file mode 100644
index 0000000..edbd9ca
--- /dev/null
+++ b/config/optional/system.action.fixity_check_delete_action.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+id: fixity_check_delete_action
+label: 'Delete check'
+type: fixity_check
+plugin: entity:delete_action:fixity_check
+configuration: { }
diff --git a/config/optional/system.action.fixity_check_periodic_disable_action.yml b/config/optional/system.action.fixity_check_periodic_disable_action.yml
new file mode 100644
index 0000000..6329d99
--- /dev/null
+++ b/config/optional/system.action.fixity_check_periodic_disable_action.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+id: fixity_check_periodic_disable_action
+label: 'Disable periodic checks'
+type: fixity_check
+plugin: dgi_fixity:periodic_disable_action:fixity_check
+configuration: { }
diff --git a/config/optional/system.action.fixity_check_periodic_enable_action.yml b/config/optional/system.action.fixity_check_periodic_enable_action.yml
new file mode 100644
index 0000000..9475265
--- /dev/null
+++ b/config/optional/system.action.fixity_check_periodic_enable_action.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+id: fixity_check_periodic_enable_action
+label: 'Enable periodic checks'
+type: fixity_check
+plugin: dgi_fixity:periodic_enable_action:fixity_check
+configuration: { }
diff --git a/config/optional/system.action.media_check_action.yml b/config/optional/system.action.media_check_action.yml
new file mode 100644
index 0000000..d5ef200
--- /dev/null
+++ b/config/optional/system.action.media_check_action.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - media
+ - dgi_fixity
+id: media_check_action
+label: 'Check media'
+type: media
+plugin: dgi_fixity:check_action:media
+configuration: { }
diff --git a/config/optional/system.action.media_periodic_disable_action.yml b/config/optional/system.action.media_periodic_disable_action.yml
new file mode 100644
index 0000000..b665200
--- /dev/null
+++ b/config/optional/system.action.media_periodic_disable_action.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+ - media
+id: media_periodic_disable_action
+label: 'Disable periodic media checks'
+type: media
+plugin: dgi_fixity:periodic_disable_action:media
+configuration: { }
diff --git a/config/optional/system.action.media_periodic_enable_action.yml b/config/optional/system.action.media_periodic_enable_action.yml
new file mode 100644
index 0000000..87fa647
--- /dev/null
+++ b/config/optional/system.action.media_periodic_enable_action.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - dgi_fixity
+ - media
+id: media_periodic_enable_action
+label: 'Enable periodic media checks'
+type: media
+plugin: dgi_fixity:periodic_enable_action:media
+configuration: { }
diff --git a/config/optional/views.view.fixity_check_source_islandora.yml b/config/optional/views.view.fixity_check_source_islandora.yml
new file mode 100644
index 0000000..6103d0d
--- /dev/null
+++ b/config/optional/views.view.fixity_check_source_islandora.yml
@@ -0,0 +1,901 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ config:
+ # Requires the following fields to work.
+ - field.storage.media.field_media_use
+ - field.storage.taxonomy_term.field_external_uri
+ module:
+ - dgi_fixity
+ module:
+ - file
+ - media
+ - taxonomy
+id: fixity_check_source_islandora
+label: 'Fixity Check Source Islandora'
+module: views
+description: 'Fixity Check Source for islandora, selects all "Original Files"'
+tag: fixity
+base_table: file_managed
+base_field: fid
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Default
+ position: 0
+ display_options:
+ access:
+ type: none
+ options: { }
+ cache:
+ type: none
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: mini
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: ‹‹
+ next: ››
+ style:
+ type: default
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ uses_fields: false
+ row:
+ type: fields
+ options:
+ inline: { }
+ separator: ''
+ hide_empty: false
+ default_field_elements: true
+ fields:
+ fid:
+ id: fid
+ table: file_managed
+ field: fid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_unformatted
+ settings: { }
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: file
+ entity_field: fid
+ plugin_id: field
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ sorts:
+ fid:
+ id: fid
+ table: file_managed
+ field: fid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ entity_type: file
+ entity_field: fid
+ plugin_id: standard
+ header: { }
+ footer: { }
+ empty: { }
+ relationships: { }
+ arguments: { }
+ display_extenders: { }
+ show_admin_links: false
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ tags: { }
+ original_files_audio:
+ display_plugin: entity_reference
+ id: original_files_audio
+ display_title: 'Original File: Audio'
+ position: 1
+ display_options:
+ display_extenders: { }
+ style:
+ type: entity_reference
+ options:
+ search_fields:
+ fid: fid
+ display_description: 'Gets the "Original File" files of Audio Media'
+ row:
+ type: entity_reference
+ options:
+ default_field_elements: false
+ inline: { }
+ separator: '-'
+ hide_empty: true
+ relationships:
+ reverse_field_media_audio_file_media:
+ id: reverse_field_media_audio_file_media
+ table: file_managed
+ field: reverse_field_media_audio_file_media
+ relationship: none
+ group_type: group
+ admin_label: Audio
+ required: true
+ entity_type: file
+ plugin_id: entity_reverse
+ field_media_use:
+ id: field_media_use
+ table: media__field_media_use
+ field: field_media_use
+ relationship: reverse_field_media_audio_file_media
+ group_type: group
+ admin_label: 'Media Use'
+ required: true
+ plugin_id: standard
+ defaults:
+ relationships: false
+ filters: false
+ filter_groups: false
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ field_external_uri_uri:
+ id: field_external_uri_uri
+ table: taxonomy_term__field_external_uri
+ field: field_external_uri_uri
+ relationship: field_media_use
+ group_type: group
+ admin_label: 'Original File'
+ operator: '='
+ value: 'http://pcdm.org/use#OriginalFile'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ plugin_id: string
+ filter_groups:
+ operator: AND
+ groups:
+ 1: AND
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ tags: { }
+ original_files_document:
+ display_plugin: entity_reference
+ id: original_files_document
+ display_title: 'Original File: Document'
+ position: 2
+ display_options:
+ display_extenders: { }
+ style:
+ type: entity_reference
+ options:
+ search_fields:
+ fid: fid
+ display_description: 'Gets the "Original File" files of Document Media'
+ row:
+ type: entity_reference
+ options:
+ default_field_elements: false
+ inline: { }
+ separator: '-'
+ hide_empty: true
+ relationships:
+ reverse_field_media_document_media:
+ id: reverse_field_media_document_media
+ table: file_managed
+ field: reverse_field_media_document_media
+ relationship: none
+ group_type: group
+ admin_label: Document
+ required: true
+ entity_type: file
+ plugin_id: entity_reverse
+ field_media_use:
+ id: field_media_use
+ table: media__field_media_use
+ field: field_media_use
+ relationship: reverse_field_media_document_media
+ group_type: group
+ admin_label: 'Media Use'
+ required: true
+ plugin_id: standard
+ defaults:
+ relationships: false
+ filters: false
+ filter_groups: false
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ field_external_uri_uri:
+ id: field_external_uri_uri
+ table: taxonomy_term__field_external_uri
+ field: field_external_uri_uri
+ relationship: field_media_use
+ group_type: group
+ admin_label: 'Original File'
+ operator: '='
+ value: 'http://pcdm.org/use#OriginalFile'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ plugin_id: string
+ filter_groups:
+ operator: AND
+ groups:
+ 1: AND
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ tags: { }
+ original_files_file:
+ display_plugin: entity_reference
+ id: original_files_file
+ display_title: 'Original File: File'
+ position: 3
+ display_options:
+ display_extenders: { }
+ style:
+ type: entity_reference
+ options:
+ search_fields:
+ fid: fid
+ display_description: 'Gets the "Original File" files of File Media'
+ row:
+ type: entity_reference
+ options:
+ default_field_elements: false
+ inline: { }
+ separator: '-'
+ hide_empty: true
+ relationships:
+ reverse_field_media_file_media:
+ id: reverse_field_media_file_media
+ table: file_managed
+ field: reverse_field_media_file_media
+ relationship: none
+ group_type: group
+ admin_label: File
+ required: true
+ entity_type: file
+ plugin_id: entity_reverse
+ field_media_use:
+ id: field_media_use
+ table: media__field_media_use
+ field: field_media_use
+ relationship: reverse_field_media_file_media
+ group_type: group
+ admin_label: 'Media Use'
+ required: true
+ plugin_id: standard
+ defaults:
+ relationships: false
+ filters: false
+ filter_groups: false
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ field_external_uri_uri:
+ id: field_external_uri_uri
+ table: taxonomy_term__field_external_uri
+ field: field_external_uri_uri
+ relationship: field_media_use
+ group_type: group
+ admin_label: 'Original File'
+ operator: '='
+ value: 'http://pcdm.org/use#OriginalFile'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ plugin_id: string
+ filter_groups:
+ operator: AND
+ groups:
+ 1: AND
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ tags: { }
+ original_files_image:
+ display_plugin: entity_reference
+ id: original_files_image
+ display_title: 'Original File: Image'
+ position: 3
+ display_options:
+ display_extenders: { }
+ style:
+ type: entity_reference
+ options:
+ search_fields:
+ fid: fid
+ display_description: 'Gets the "Original File" files of Image Media'
+ row:
+ type: entity_reference
+ options:
+ default_field_elements: false
+ inline: { }
+ separator: '-'
+ hide_empty: true
+ relationships:
+ reverse_field_media_image_media:
+ id: reverse_field_media_image_media
+ table: file_managed
+ field: reverse_field_media_image_media
+ relationship: none
+ group_type: group
+ admin_label: Image
+ required: true
+ entity_type: file
+ plugin_id: entity_reverse
+ field_media_use:
+ id: field_media_use
+ table: media__field_media_use
+ field: field_media_use
+ relationship: reverse_field_media_image_media
+ group_type: group
+ admin_label: 'Media Use'
+ required: true
+ plugin_id: standard
+ defaults:
+ relationships: false
+ filters: false
+ filter_groups: false
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ field_external_uri_uri:
+ id: field_external_uri_uri
+ table: taxonomy_term__field_external_uri
+ field: field_external_uri_uri
+ relationship: field_media_use
+ group_type: group
+ admin_label: 'Original File'
+ operator: '='
+ value: 'http://pcdm.org/use#OriginalFile'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ plugin_id: string
+ filter_groups:
+ operator: AND
+ groups:
+ 1: AND
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ tags: { }
+ original_files_video:
+ display_plugin: entity_reference
+ id: original_files_video
+ display_title: 'Original File: Video'
+ position: 3
+ display_options:
+ display_extenders: { }
+ style:
+ type: entity_reference
+ options:
+ search_fields:
+ fid: fid
+ display_description: 'Gets the "Original File" files of Video Media'
+ row:
+ type: entity_reference
+ options:
+ default_field_elements: false
+ inline: { }
+ separator: '-'
+ hide_empty: true
+ relationships:
+ reverse_field_media_video_file_media:
+ id: reverse_field_media_video_file_media
+ table: file_managed
+ field: reverse_field_media_video_file_media
+ relationship: none
+ group_type: group
+ admin_label: Video
+ required: true
+ entity_type: file
+ plugin_id: entity_reverse
+ field_media_use:
+ id: field_media_use
+ table: media__field_media_use
+ field: field_media_use
+ relationship: reverse_field_media_video_file_media
+ group_type: group
+ admin_label: 'Media Use'
+ required: true
+ plugin_id: standard
+ defaults:
+ relationships: false
+ filters: false
+ filter_groups: false
+ filters:
+ status:
+ id: status
+ table: file_managed
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: in
+ value:
+ 1: '1'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ reduce: false
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: file
+ entity_field: status
+ plugin_id: file_status
+ field_external_uri_uri:
+ id: field_external_uri_uri
+ table: taxonomy_term__field_external_uri
+ field: field_external_uri_uri
+ relationship: field_media_use
+ group_type: group
+ admin_label: ''
+ operator: '='
+ value: 'http://pcdm.org/use#OriginalFile'
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ plugin_id: string
+ filter_groups:
+ operator: AND
+ groups:
+ 1: AND
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ tags: { }
diff --git a/config/schema/dgi_fixity.schema.yml b/config/schema/dgi_fixity.schema.yml
new file mode 100644
index 0000000..3530c2b
--- /dev/null
+++ b/config/schema/dgi_fixity.schema.yml
@@ -0,0 +1,25 @@
+dgi_fixity.settings:
+ type: config_object
+ label: 'Fixity check settings'
+ mapping:
+ sources:
+ type: sequence
+ label: 'File Selection for Fixity Checks'
+ sequence:
+ type: string
+ label: 'View and Display Identifier'
+ threshold:
+ type: string
+ label: 'Time elapsed between Fixity Checks'
+ batch_size:
+ type: integer
+ label: 'How many files will be processed at once when performing a batch / cron job'
+ notify_status:
+ type: integer
+ label: 'Notification trigger on status'
+ notify_user:
+ type: integer
+ label: 'User to notify'
+ notify_user_threshold:
+ type: string
+ label: 'Time elapsed between notifications'
diff --git a/dgi_fixity.info.yml b/dgi_fixity.info.yml
new file mode 100644
index 0000000..6a6dbde
--- /dev/null
+++ b/dgi_fixity.info.yml
@@ -0,0 +1,12 @@
+name: 'Fixity'
+description: "Performs fixity checks on files."
+type: module
+package: DGI
+core_version_requirement: ^8 || ^9
+configure: dgi_fixity.settings
+dependencies:
+ - drupal:file
+ - drupal:media
+ - drupal:user
+ - drupal:views
+ - filehash:filehash
diff --git a/dgi_fixity.install b/dgi_fixity.install
new file mode 100644
index 0000000..592b891
--- /dev/null
+++ b/dgi_fixity.install
@@ -0,0 +1,34 @@
+stats();
+ $elements = [];
+ foreach ($fixity->summary($stats) as $summary) {
+ $elements[] = [
+ '#markup' => $summary,
+ '#suffix' => '
',
+ ];
+ }
+ $failed = $stats['failed'] > 0;
+ $out_to_date = $stats['periodic']['expired'] > 0;
+ $requirements['dgi_fixity'] = [
+ 'title' => \t('Fixity'),
+ 'value' => $failed ? \t('Error') : ($out_to_date ? \t('Out of date') : \t('Up to date')),
+ 'description' => \Drupal::service('renderer')->render($elements),
+ 'severity' => $failed ? REQUIREMENT_ERROR : ($out_to_date ? REQUIREMENT_WARNING : REQUIREMENT_OK),
+ ];
+ }
+ return $requirements;
+}
diff --git a/dgi_fixity.links.menu.yml b/dgi_fixity.links.menu.yml
new file mode 100644
index 0000000..d3855b8
--- /dev/null
+++ b/dgi_fixity.links.menu.yml
@@ -0,0 +1,11 @@
+dgi_fixity.settings:
+ title: 'Fixity'
+ description: 'Configure Fixity Settings.'
+ route_name: dgi_fixity.settings
+ parent: 'system.admin_config_media'
+
+dgi_fixity.report:
+ title: 'Fixity report'
+ description: 'Report of all fixity checks.'
+ route_name: entity.fixity_check.collection
+ parent: 'system.admin_reports'
diff --git a/dgi_fixity.links.task.yml b/dgi_fixity.links.task.yml
new file mode 100644
index 0000000..fc59ee7
--- /dev/null
+++ b/dgi_fixity.links.task.yml
@@ -0,0 +1,45 @@
+dgi_fixity.entities:
+ class: \Drupal\Core\Menu\LocalTaskDefault
+ deriver: 'Drupal\dgi_fixity\Plugin\Derivative\FixityCheckLocalTasks'
+
+dgi_fixity.config:
+ route_name: dgi_fixity.settings
+ base_route: dgi_fixity.settings
+ title: Configuration
+
+dgi_fixity.batch:
+ route_name: dgi_fixity.batch
+ base_route: dgi_fixity.settings
+ title: Check
+
+dgi_fixity.generate:
+ route_name: dgi_fixity.generate
+ base_route: dgi_fixity.settings
+ title: Generate
+
+entity.fixity_check.view:
+ route_name: entity.fixity_check.canonical
+ base_route: entity.fixity_check.canonical
+ title: View
+
+entity.fixity_check.edit:
+ route_name: entity.fixity_check.edit_form
+ base_route: entity.fixity_check.canonical
+ title: Edit
+
+entity.fixity_check.delete_form:
+ route_name: entity.fixity_check.delete_form
+ base_route: entity.fixity_check.canonical
+ weight: 10
+ title: Delete
+
+entity.fixity_check.revision:
+ route_name: entity.fixity_check.revision
+ base_route: entity.fixity_check.revision
+ title: Revision
+
+entity.fixity_check.revision.delete_form:
+ route_name: entity.fixity_check.revision_delete_confirm
+ base_route: entity.fixity_check.revision
+ weight: 10
+ title: Delete
diff --git a/dgi_fixity.module b/dgi_fixity.module
new file mode 100644
index 0000000..289a5e0
--- /dev/null
+++ b/dgi_fixity.module
@@ -0,0 +1,233 @@
+getModule('dgi_fixity')
+ ->getPath() . '/' . InstallStorage::CONFIG_OPTIONAL_DIRECTORY;
+ /** @var \Drupal\Core\Config\ConfigInstallerInterface $config_installer */
+ $config_installer = \Drupal::service('config.installer');
+ $storage = new FileStorage($optional_install_path, StorageInterface::DEFAULT_COLLECTION);
+ // This will not overwrite the existing optional configuration if already
+ // installed.
+ $config_installer->installOptionalConfig($storage);
+ }
+}
+
+/**
+ * Implements hook_mail().
+ */
+function dgi_fixity_mail($key, &$message, $params) {
+ switch ($key) {
+ case 'notify':
+ $config = \Drupal::config(SettingsForm::CONFIG_NAME);
+ $last = \Drupal::state()->get(SettingsForm::STATE_LAST_NOTIFICATION);
+
+ if ($last !== NULL) {
+ // If enough time has not elapsed since the last notification do not
+ // send again.
+ $threshold = strtotime($config->get(SettingsForm::NOTIFY_USER_THRESHOLD));
+ if ($last > $threshold) {
+ $message['send'] = FALSE;
+ return;
+ }
+ }
+
+ // Check if the configuration has enabled notifications.
+ $notify_status = $config->get(SettingsForm::NOTIFY_STATUS);
+ if ($notify_status === SettingsForm::NOTIFY_STATUS_NEVER) {
+ $message['send'] = FALSE;
+ return;
+ }
+
+ /** @var \Drupal\dgi_fixity\FixityCheckServiceInterface $fixity */
+ $fixity = \Drupal::service('dgi_fixity.fixity_check');
+ $stats = $fixity->stats();
+ // Only notify if an error has occurred.
+ if ($notify_status == SettingsForm::NOTIFY_STATUS_ERROR && $stats['failed'] === FALSE) {
+ $message['send'] = FALSE;
+ return;
+ }
+
+ $options = ['langcode' => $message['langcode']];
+ $now = \Drupal::time()->getRequestTime();
+ $subject = \t('Fixity Check Report - @now', ['@now' => date(DATE_RFC7231, $now)], $options)->render();
+ $body = $fixity->summary($stats, $options);
+ if ($stats['failed'] !== 0) {
+ $body[] = \t(
+ 'There are failed checks which require your attention please review the current state of checks here.',
+ ['@site' => Url::fromRoute('entity.fixity_check.collection', [], ['absolute' => TRUE])->toString()],
+ $options
+ )->render();
+ }
+
+ $message['subject'] = $subject;
+ foreach ($body as $line) {
+ $message['body'][] = MailFormatHelper::htmlToText($line);
+ }
+
+ // Track when the last message was sent.
+ \Drupal::state()->set(SettingsForm::STATE_LAST_NOTIFICATION, $now);
+ break;
+ }
+}
+
+/**
+ * Implements hook_cron().
+ */
+function dgi_fixity_cron() {
+ $queued = \Drupal::time()->getRequestTime();
+ $settings = \Drupal::config(SettingsForm::CONFIG_NAME);
+ $threshold = strtotime($settings->get(SettingsForm::THRESHOLD));
+ $sources = $settings->get(SettingsForm::SOURCES);
+
+ // Update enabled periodic checks.
+ $queue = \Drupal::queue('dgi_fixity.process_source');
+ foreach ($sources as $source) {
+ // It safe to have queue processing a source multiple times,
+ // they will steal work from each other but will not conflict.
+ $queue->createItem($source);
+ }
+
+ /** @var \Drupal\dgi_fixity\FixityCheckStorageInterface $storage */
+ $storage = \Drupal::entityTypeManager()->getStorage('fixity_check');
+
+ // Queue items that exceed the current threshold.
+ $storage->queue($queued, $threshold, 100);
+
+ // Dequeued items after 6 hours assuming the check has failed.
+ // They will be re-queued if appropriate on the next cron run.
+ $storage->dequeue($queued - (3600 * 6));
+
+ // Send notification if appropriate.
+ $uid = $settings->get(SettingsForm::NOTIFY_USER);
+ $user = User::load($uid);
+ if ($user) {
+ \Drupal::service('plugin.manager.mail')->mail('dgi_fixity', 'notify', $user->getEmail(), $user->getPreferredLangcode(TRUE));
+ }
+}
+
+/**
+ * Implements hook_entity_type_alter().
+ */
+function dgi_fixity_entity_type_alter(array &$entity_types) {
+ /** @var \Drupal\dgi_fixity\FixityCheckServiceInterface $fixity */
+ $fixity = \Drupal::service('dgi_fixity.fixity_check');
+ $supported_entity_types = $fixity->fromEntityTypes();
+ foreach ($supported_entity_types as $entity_type_id) {
+ $entity_type = &$entity_types[$entity_type_id];
+ $entity_type->setLinkTemplate('fixity-audit', "/fixity/$entity_type_id/{{$entity_type_id}}");
+ $entity_type->setLinkTemplate('fixity-check', "/fixity/$entity_type_id/{{$entity_type_id}}/check");
+ $entity_type->setFormClass('fixity-check', 'Drupal\dgi_fixity\Form\CheckForm');
+ }
+}
+
+/**
+ * Implements hook_entity_operation().
+ */
+function dgi_fixity_entity_operation(EntityInterface $entity) {
+ $current_user = \Drupal::service('current_user');
+ $operations = [];
+ if ($entity->hasLinkTemplate('fixity-audit') && $current_user->hasPermission('view fixity checks')) {
+ $operations['fixity-audit'] = [
+ 'title' => \t('Audit'),
+ 'weight' => 10,
+ 'url' => $entity->toUrl('fixity-audit'),
+ ];
+ if ($entity->hasLinkTemplate('fixity-check') && $current_user->hasPermission('administer fixity checks')) {
+ $operations['fixity-check'] = [
+ 'title' => \t('Check'),
+ 'weight' => 13,
+ 'url' => $entity->toUrl('fixity-check', ['query' => \Drupal::service('redirect.destination')->getAsArray()]),
+ ];
+ }
+ }
+ return $operations;
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_insert().
+ */
+function dgi_fixity_file_insert(EntityInterface $entity) {
+ // Make sure the fixity_check table contains a row for every file.
+ \Drupal::entityTypeManager()
+ ->getStorage('fixity_check')
+ ->create([
+ 'file' => $entity->id(),
+ ])
+ ->save();
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_delete().
+ */
+function dgi_fixity_file_delete(EntityInterface $entity) {
+ /** @var \Drupal\dgi_fixity\FixityCheckStorageInterface $storage */
+ $storage = \Drupal::entityTypeManager()->getStorage('fixity_check');
+ $checks = $storage->loadByProperties([
+ 'file' => $entity->id(),
+ ]);
+ // Remove checks for non-existent files.
+ $storage->delete($checks);
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_revision_create().
+ */
+function dgi_fixity_fixity_check_revision_create(EntityInterface $entity) {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $entity*/
+ Cache::invalidateTags($entity->getAuditCacheTags());
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_revision_delete().
+ */
+function dgi_fixity_fixity_check_revision_delete(EntityInterface $entity) {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $entity*/
+ Cache::invalidateTags($entity->getAuditCacheTags());
+}
+
+/**
+ * Implements hook_help().
+ */
+function dgi_fixity_help($route_name, RouteMatchInterface $route_match) {
+ switch ($route_name) {
+ case 'help.page.dgi_fixity':
+ case 'dgi_fixity.settings':
+ $output = array_fill(0, 2, ['#type' => 'html_tag', '#tag' => 'p']);
+ $output[0]['#value'] = \t(
+ 'The Fixity module validates selected files by generating hashes and comparing it against stored values produced by the File Hash module for selected files uploaded to the site.',
+ ['@file_hash' => URL::fromRoute('help.page', ['name' => 'filehash'])->toString()],
+ );
+ return $output;
+ }
+}
diff --git a/dgi_fixity.permissions.yml b/dgi_fixity.permissions.yml
new file mode 100644
index 0000000..b6f083a
--- /dev/null
+++ b/dgi_fixity.permissions.yml
@@ -0,0 +1,4 @@
+administer fixity checks:
+ title: 'Administer Fixity Checks'
+view fixity checks:
+ title: 'View Fixity Checks'
diff --git a/dgi_fixity.routing.yml b/dgi_fixity.routing.yml
new file mode 100644
index 0000000..aecf3d1
--- /dev/null
+++ b/dgi_fixity.routing.yml
@@ -0,0 +1,57 @@
+dgi_fixity.settings:
+ path: '/admin/config/fixity'
+ defaults:
+ _form: '\Drupal\dgi_fixity\Form\SettingsForm'
+ _title: 'Fixity'
+ requirements:
+ _permission: 'access administration pages,administer site configuration,administer fixity checks'
+
+dgi_fixity.batch:
+ path: '/admin/config/fixity/check'
+ defaults:
+ _form: '\Drupal\dgi_fixity\Form\BatchForm'
+ _title: 'Check'
+ requirements:
+ _permission: 'access administration pages,administer fixity checks'
+
+dgi_fixity.generate:
+ path: '/admin/config/fixity/generate'
+ defaults:
+ _form: '\Drupal\dgi_fixity\Form\GenerateForm'
+ _title: 'Generate'
+ requirements:
+ _permission: 'access administration pages,administer fixity checks'
+
+entity.fixity_check.revision:
+ path: '/fixity/{fixity_check}/revisions/{fixity_check_revision}'
+ defaults:
+ _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision'
+ _title_callback: '\Drupal\Core\Entity\Controller\EntityController::title'
+ requirements:
+ _entity_access: 'fixity_check_revision.view revision'
+ fixity_check: \d+
+ fixity_check_revision: \d+
+ options:
+ _admin_route: TRUE
+ parameters:
+ fixity_check:
+ type: entity:fixity_check
+ fixity_check_revision:
+ type: entity_revision:fixity_check
+
+entity.fixity_check.revision_delete_confirm:
+ path: '/fixity/{fixity_check}/revisions/{fixity_check_revision}/delete'
+ defaults:
+ _form: '\Drupal\dgi_fixity\Form\RevisionDeleteForm'
+ _title: 'Delete earlier check'
+ requirements:
+ _entity_access: 'fixity_check_revision.delete revision'
+ fixity_check: \d+
+ fixity_check_revision: \d+
+ options:
+ _admin_route: TRUE
+ parameters:
+ fixity_check:
+ type: entity:fixity_check
+ fixity_check_revision:
+ type: entity_revision:fixity_check
diff --git a/dgi_fixity.services.yml b/dgi_fixity.services.yml
new file mode 100644
index 0000000..ae9da2e
--- /dev/null
+++ b/dgi_fixity.services.yml
@@ -0,0 +1,18 @@
+services:
+ logger.channel.dgi_fixity:
+ class: Drupal\Core\Logger\LoggerChannel
+ factory: logger.factory:get
+ arguments: ['dgi_fixity']
+ dgi_fixity.fixity_check:
+ class: Drupal\dgi_fixity\FixityCheckService
+ arguments: ['@string_translation', '@config.factory', '@entity_type.manager', '@datetime.time', '@plugin.manager.mail', '@logger.channel.dgi_fixity', '@filehash']
+ dgi_fixity.route_subscriber:
+ class: Drupal\dgi_fixity\Routing\FixityCheckRouteSubscriber
+ arguments: ['@entity_type.manager', '@dgi_fixity.fixity_check']
+ tags:
+ - { name: event_subscriber }
+ dgi_fixity.paramconverter.fixity:
+ class: Drupal\dgi_fixity\Routing\FixityCheckConverter
+ arguments: ['@entity_type.manager', '@entity.repository', '@dgi_fixity.fixity_check']
+ tags:
+ - { name: paramconverter }
diff --git a/dgi_fixity.views.inc b/dgi_fixity.views.inc
new file mode 100644
index 0000000..a4bef83
--- /dev/null
+++ b/dgi_fixity.views.inc
@@ -0,0 +1,54 @@
+getDefinition($entity_type_id);
+
+ /** @var \Drupal\Core\Entity\EntityFieldManager $entity_field_manager */
+ $entity_field_manager = \Drupal::service('entity_field.manager');
+ $field_definitions = $entity_field_manager->getBaseFieldDefinitions($entity_type_id);
+ $field_type = $field_definitions[$field_id];
+
+ // Allow relations to both the entity and revision base table.
+ // The fixity_check entity does use data tables.
+ $tables = [$entity_type->getBaseTable(), $entity_type->getRevisionTable()];
+ foreach ($tables as $table) {
+ $group = $data[$table]['table']['group'];
+ $pseudo_field_name = 'reverse_' . $field_type->getName() . '_' . $table;
+ $data['file_managed'][$pseudo_field_name] = [
+ 'real field' => $field_type->getName(),
+ 'relationship' => [
+ 'title' => \t('@entity using @field',
+ [
+ '@entity' => $entity_type->getLabel(),
+ '@field' => $field_type->getLabel(),
+ ],
+ ),
+ 'label' => $group,
+ 'help' => \t('Relate each @entity with a @field set to the file.',
+ [
+ '@entity' => $entity_type->getLabel(),
+ '@field' => $field_type->getLabel(),
+ ],
+ ),
+ 'group' => $group,
+ 'id' => 'standard',
+ 'base' => $table,
+ 'base field' => $field_type->getName(),
+ 'relationship field' => 'fid',
+ ],
+ ];
+ }
+}
diff --git a/drush.services.yml b/drush.services.yml
new file mode 100644
index 0000000..5123c7e
--- /dev/null
+++ b/drush.services.yml
@@ -0,0 +1,6 @@
+services:
+ dgi_fixity.commands.fixity_check:
+ class: \Drupal\dgi_fixity\Commands\FixityCheck
+ arguments: ['@string_translation', '@logger.dblog', '@entity_type.manager']
+ tags:
+ - { name: drush.command }
diff --git a/src/Commands/FixityCheck.php b/src/Commands/FixityCheck.php
new file mode 100644
index 0000000..0f40542
--- /dev/null
+++ b/src/Commands/FixityCheck.php
@@ -0,0 +1,113 @@
+stringTranslation = $string_translation;
+ $this->logger = $logger;
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * Sets the periodic check flag to FALSE for all files.
+ *
+ * @command dgi_fixity:clear
+ */
+ public function clear() {
+ /** @var \Drupal\dgi_fixity\FixityCheckStorageInterface $storage */
+ $storage = $this->entityTypeManager->getStorage('fixity_check');
+ $count = $storage->countPeriodic();
+ if ($this->io()->confirm("This will remove periodic checks on ${count} files, are you sure?", FALSE)) {
+ $storage->clearPeriodic();
+ }
+ }
+
+ /**
+ * Creates a fixity_check entity for all previously created files.
+ *
+ * @command dgi_fixity:generate
+ */
+ public function generate() {
+ $batch = FixityCheckBatchGenerate::build();
+ batch_set($batch);
+ drush_backend_batch_process();
+ }
+
+ /**
+ * Perform fixity checks on files.
+ *
+ * @option fids Comma separated list of file identifiers, or a path to a
+ * file containing file identifiers. The file should have each
+ * fid separated by a new line. If not specified the modules
+ * settings for sources is used to determine which files to
+ * check.
+ * @option force Skip time elapsed threshold check when processing files.
+ *
+ * @command dgi_fixity:check
+ */
+ public function check(array $options = [
+ 'fids' => NULL,
+ 'force' => FALSE,
+ ]) {
+ $fids = $options['fids'];
+ if (!is_null($fids)) {
+ // If a file path is provided, parse it.
+ if (is_file($fids)) {
+ if (is_readable($fids)) {
+ $fids = explode("\n", trim(file_get_contents($fids)));
+ }
+ else {
+ $this->logger->error($this->t('Cannot read file @file', ['@file' => $fids]));
+ return;
+ }
+ }
+ else {
+ $fids = explode(',', $fids);
+ }
+ }
+ $batch = FixityCheckBatchCheck::build($fids, $options['force']);
+ batch_set($batch);
+ drush_backend_batch_process();
+ }
+
+}
diff --git a/src/Controller/FixityCheckController.php b/src/Controller/FixityCheckController.php
new file mode 100644
index 0000000..768d753
--- /dev/null
+++ b/src/Controller/FixityCheckController.php
@@ -0,0 +1,259 @@
+dateFormatter = $date_formatter;
+ $this->renderer = $renderer;
+ $this->fixity = $fixity;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('date.formatter'),
+ $container->get('renderer'),
+ $container->get('dgi_fixity.fixity_check'),
+ );
+ }
+
+ /**
+ * Returns the audit display for the current entity.
+ *
+ * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+ * A RouteMatch object.
+ *
+ * @return array
+ * Array of page elements to render.
+ */
+ public function entityAudit(RouteMatchInterface $route_match) {
+ $entity = $this->getEntityFromRouteMatch($route_match);
+ return $this->audit($entity);
+ }
+
+ /**
+ * Generates an overview table of all revisions of a given fixity_check.
+ *
+ * @param \Drupal\dgi_fixity\FixityCheckInterface $fixity_check
+ * A fixity_check entity.
+ *
+ * @return array
+ * An array expected by \Drupal\Core\Render\RendererInterface::render().
+ */
+ public function audit(FixityCheckInterface $fixity_check) {
+ $account = $this->currentUser();
+ $storage = $this->entityTypeManager()->getStorage('fixity_check');
+
+ $build['#title'] = $this->t('Audit for %title', [
+ '%title' => $fixity_check->label(),
+ ]);
+
+ $header = [
+ $this->t('Performed'),
+ $this->t('State'),
+ $this->t('Operations'),
+ ];
+
+ $canDelete = $account->hasPermission('administer fixity checks') && $fixity_check->access('delete');
+
+ $rows = [];
+ $defaultRevision = $fixity_check->getRevisionId();
+ $currentRevisionDisplayed = FALSE;
+
+ foreach ($this->getRevisionIds($fixity_check, $storage) as $revision_id) {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $revision */
+ $revision = $storage->loadRevision($revision_id);
+
+ // Use timestamp rather than timestamp_ago to allow for caching.
+ $date = ($revision->wasPerformed()) ?
+ $revision->performed->view([
+ 'label' => 'hidden',
+ 'type' => 'timestamp',
+ ]) :
+ $this->t('never');
+
+ $isCurrentRevision = $revision_id == $defaultRevision || (!$currentRevisionDisplayed && $revision->wasDefaultRevision());
+ if (!$isCurrentRevision) {
+ $link = Link::fromTextAndUrl($date, new Url(
+ 'entity.fixity_check.revision',
+ [
+ 'fixity_check' => $fixity_check->id(),
+ 'fixity_check_revision' => $revision_id,
+ ]
+ ));
+ }
+ else {
+ $link = $fixity_check->toLink($date);
+ $currentRevisionDisplayed = TRUE;
+ }
+
+ $state = $revision->state->view([
+ 'label' => 'hidden',
+ 'type' => 'dgi_fixity_state',
+ ]);
+
+ $row = [
+ [
+ 'data' => $link->toRenderable(),
+ ],
+ [
+ 'data' => $state,
+ ],
+ ];
+
+ if ($isCurrentRevision) {
+ $row[] = [
+ 'data' => [
+ '#prefix' => '',
+ '#markup' => $this->t('Current revision'),
+ '#suffix' => '',
+ ],
+ ];
+
+ $rows[] = [
+ 'data' => $row,
+ 'class' => ['revision-current'],
+ ];
+ }
+ else {
+ $links = [];
+
+ if ($canDelete) {
+ $links['delete'] = [
+ 'title' => $this->t('Delete'),
+ 'url' => Url::fromRoute(
+ 'entity.fixity_check.revision_delete_confirm',
+ [
+ 'fixity_check' => $fixity_check->id(),
+ 'fixity_check_revision' => $revision_id,
+ ]
+ ),
+ ];
+ }
+
+ $row[] = [
+ 'data' => [
+ '#type' => 'operations',
+ '#links' => $links,
+ ],
+ ];
+
+ $rows[] = $row;
+ }
+ }
+
+ $build['fixity_check_revisions_table'] = [
+ '#theme' => 'table',
+ '#rows' => $rows,
+ '#header' => $header,
+ '#attributes' => [
+ 'class' => 'fixity_check-revision-table',
+ ],
+ ];
+ $build['pager'] = [
+ '#type' => 'pager',
+ ];
+
+ $build['#cache'] = [
+ 'keys' => [
+ 'entity_view', 'fixity_check', $fixity_check->id(), 'revisions',
+ ],
+ 'contexts' => [
+ // Date displayed varies by timezone.
+ 'timezone',
+ ],
+ 'tags' => $fixity_check->getAuditCacheTags(),
+ 'bin' => 'render',
+ ];
+ $this->renderer->addCacheableDependency($build, $fixity_check);
+ return $build;
+ }
+
+ /**
+ * Gets a list of fixity_check revision IDs for a given fixity_check.
+ *
+ * @param \Drupal\dgi_fixity\FixityCheckInterface $fixity_check
+ * Media entity to search for revisions.
+ * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+ * Media storage to load revisions from.
+ *
+ * @return int[]
+ * fixity_check revision IDs in descending order.
+ */
+ protected function getRevisionIds(FixityCheckInterface $fixity_check, EntityStorageInterface $storage) {
+ $result = $storage->getQuery()
+ ->allRevisions()
+ ->condition('id', $fixity_check->id())
+ ->sort('performed', 'DESC')
+ ->pager(50)
+ ->execute();
+ return array_keys($result);
+ }
+
+ /**
+ * Retrieves entity from route match.
+ *
+ * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+ * The route match.
+ *
+ * @return \Drupal\dgi_fixity\FixityCheckInterface|null
+ * The fixity check entity from the passed-in route match.
+ */
+ protected function getEntityFromRouteMatch(RouteMatchInterface $route_match): ?FixityCheckInterface {
+ // Option added by Route Subscriber.
+ $parameter_name = $route_match->getRouteObject()->getOption('_fixity_entity_type_id');
+ return ($parameter_name == 'fixity_check') ?
+ $route_match->getParameter($parameter_name) :
+ $this->fixity->fromEntity($route_match->getParameter($parameter_name));
+ }
+
+}
diff --git a/src/Entity/FixityCheck.php b/src/Entity/FixityCheck.php
new file mode 100644
index 0000000..1c95791
--- /dev/null
+++ b/src/Entity/FixityCheck.php
@@ -0,0 +1,343 @@
+setLabel(\t('File'))
+ ->setDescription(\t('The file entity the fixity check was performed against.'))
+ ->setRequired(TRUE)
+ ->setRevisionable(FALSE)
+ ->setTranslatable(FALSE)
+ // It's not possible to have two fixity checks for the same file, they
+ // should result in different versions of the same fixity_check entity.
+ ->addConstraint('UniqueFieldEntityReference')
+ ->setSetting('target_type', 'file')
+ ->setDisplayConfigurable('view', FALSE)
+ ->setDisplayOptions('view', [
+ 'type' => 'dgi_fixity_file_reference',
+ 'weight' => 0,
+ ]);
+
+ $fields['state'] = BaseFieldDefinition::create('integer')
+ ->setLabel(\t('State'))
+ ->setDescription(\t('A flag indicating the state of the whether the check passed or not.'))
+ ->setTranslatable(FALSE)
+ ->setRevisionable(TRUE)
+ ->setInitialValue(static::STATE_UNDEFINED)
+ ->setDefaultValue(static::STATE_UNDEFINED)
+ // Define this via an options provider once.
+ // https://www.drupal.org/node/2329937 is completed.
+ ->addPropertyConstraints('value', [
+ 'AllowedValues' => ['callback' => static::class . '::getAllowedStates'],
+ ])
+ ->setDisplayConfigurable('view', FALSE)
+ ->setDisplayOptions('view', [
+ 'type' => 'dgi_fixity_state',
+ 'weight' => 1,
+ ]);
+
+ // A value of 0 indicates the check was never performed even though it is
+ // a unix-timestamp, which means it is technically 1970-01-01 00:00:00.
+ $fields['performed'] = BaseFieldDefinition::create('timestamp')
+ ->setLabel(t('Performed'))
+ ->setDescription(t('The time the check was performed, 0 if never performed.'))
+ ->setTranslatable(FALSE)
+ ->setRevisionable(TRUE)
+ ->setInitialValue(0)
+ ->setDefaultValue(0)
+ ->setDisplayConfigurable('view', FALSE)
+ ->setDisplayOptions('view', [
+ 'type' => 'timestamp_ago',
+ 'weight' => 2,
+ ]);
+
+ $fields['periodic'] = BaseFieldDefinition::create('boolean')
+ ->setLabel(t('Periodic'))
+ ->setDescription(t('Enable/disable periodic fixity checks.'))
+ ->setRequired(TRUE)
+ ->setTranslatable(FALSE)
+ ->setRevisionable(FALSE)
+ ->setInitialValue(FALSE)
+ ->setDefaultValue(FALSE)
+ ->setDisplayConfigurable('view', FALSE)
+ ->setDisplayOptions('form', [
+ 'type' => 'options_buttons',
+ 'weight' => 3,
+ ])
+ ->setDisplayOptions('view', [
+ 'type' => 'boolean',
+ 'weight' => 3,
+ ]);
+
+ $fields['queued'] = BaseFieldDefinition::create('timestamp')
+ ->setLabel(t('Queued'))
+ ->setDescription(t('Time when this file was queued for fixity, 0 if not queued.'))
+ ->setTranslatable(FALSE)
+ ->setRevisionable(FALSE)
+ ->setInitialValue(0)
+ ->setDefaultValue(0)
+ ->setDisplayConfigurable('view', FALSE)
+ ->setDisplayOptions('view', [
+ 'region' => 'hidden',
+ ]);
+
+ return $fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preSave(EntityStorageInterface $storage) {
+ parent::preSave($storage);
+ // Disallow changes to state / performed on existing revisions.
+ // Unless this is the first revision and it the check was never performed.
+ if (!$this->isNewRevision() && !($this->isLatestRevision() && !$this->original->wasPerformed())) {
+ $immutable_fields = ['state', 'performed'];
+ foreach ($immutable_fields as $field) {
+ if ($this->{$field}->hasAffectingChanges($this->original->{$field}, LanguageInterface::LANGCODE_NOT_SPECIFIED)) {
+ throw new \LogicException("Entity type {$this->getEntityTypeId()} does not support modifying the '{$field}' field of existing revisions.");
+ }
+ }
+ }
+ // The file field is immutable after creation.
+ if ($this->original && $this->file->hasAffectingChanges($this->original->file, LanguageInterface::LANGCODE_NOT_SPECIFIED)) {
+ throw new \LogicException("Entity type {$this->getEntityTypeId()} does not support modifying the file field after creation.");
+ }
+ // If performed has changed force queued to zero.
+ if ($this->original && $this->performed->hasAffectingChanges($this->original->performed, LanguageInterface::LANGCODE_NOT_SPECIFIED)) {
+ $this->setQueued(0);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preSaveRevision(EntityStorageInterface $storage, \stdClass $record) {
+ parent::preSaveRevision($storage, $record);
+ // Disallow creating revisions if performed is not set.
+ if (!$this->isNew() && $this->isNewRevision() && $record->performed === 0) {
+ throw new \LogicException("Entity type {$this->getEntityTypeId()} does not support creating new revisions without where the performed field is not set.");
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setNewRevision($value = TRUE) {
+ parent::setNewRevision($value);
+ // Reset the state and performed timestamp when creating a new revision
+ // from an existing fixity_check.
+ if (!$this->isNew() && $value) {
+ $this->state = static::STATE_UNDEFINED;
+ unset($this->performed);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFile(): ?File {
+ /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $file */
+ $file = $this->file;
+ return $file->isEmpty() ? NULL : $file->referencedEntities()[0];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setFile(File $file): FixityCheckInterface {
+ $this->set('file', $file);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getState(): int {
+ return $this->state->value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setState(int $state): FixityCheckInterface {
+ if (!in_array($state, static::getAllowedStates())) {
+ throw new \InvalidArgumentException("Invalid state '$state' has been given");
+ }
+ $this->set('state', $state);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getStateLabel(): string {
+ return static::getStateProperty($this->getState(), 'label');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getStateProperty(int $state, string $property) {
+ return static::STATES[$state][$property] ?? NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function passed(): bool {
+ $state = $this->getState();
+ return static::STATES[$state]['passed'] ?? FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPeriodic(): bool {
+ return $this->periodic->value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setPeriodic(bool $periodic): FixityCheckInterface {
+ $this->set('periodic', $periodic);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPerformed(): int {
+ return $this->performed->value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setPerformed(int $performed): FixityCheckInterface {
+ $this->set('performed', $performed);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function wasPerformed(): bool {
+ return $this->getPerformed() !== 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getQueued(): int {
+ return $this->queued->value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setQueued(int $queued): FixityCheckInterface {
+ $this->set('queued', $queued);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function label() {
+ $file = $this->getFile();
+ return ($file === NULL) ?
+ $this->t('Fixity Check') :
+ $file->label();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAuditCacheTags() {
+ return [
+ 'fixity_check:' . $this->id() . ':revisions_list',
+ ];
+ }
+
+ /**
+ * Defines allowed states for AllowedValues constraints.
+ *
+ * @return int[]
+ * The allowed states.
+ */
+ public static function getAllowedStates() {
+ return array_keys(static::STATES);
+ }
+
+}
diff --git a/src/FixityCheckAccessControlHandler.php b/src/FixityCheckAccessControlHandler.php
new file mode 100644
index 0000000..0fbf552
--- /dev/null
+++ b/src/FixityCheckAccessControlHandler.php
@@ -0,0 +1,50 @@
+entityType->getAdminPermission();
+
+ switch ($operation) {
+ case 'view':
+ case 'view revision':
+ return AccessResult::allowedIfHasPermission($account, 'view fixity checks')->cachePerPermissions();
+
+ case 'delete':
+ return AccessResult::allowedIfHasPermission($account, $admin_permission)->cachePerPermissions();
+
+ case 'delete revision':
+ // Not possible to delete the default revision, instead the user
+ // should delete the actual entity.
+ if ($entity->isDefaultRevision()) {
+ return AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+ return AccessResult::allowedIfHasPermission($account, $admin_permission)->cachePerPermissions();
+
+ default:
+ return AccessResult::forbidden()->cachePerPermissions();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+ return AccessResult::allowedIfHasPermission($account, $this->entityType->getAdminPermission());
+ }
+
+}
diff --git a/src/FixityCheckBatchCheck.php b/src/FixityCheckBatchCheck.php
new file mode 100644
index 0000000..e0aa93c
--- /dev/null
+++ b/src/FixityCheckBatchCheck.php
@@ -0,0 +1,286 @@
+get(SettingsForm::BATCH_SIZE) : $batch_size;
+ return is_null($fids) ?
+ static::buildPeriodic($force, $batch_size) :
+ static::buildFixed($fids, $force, $batch_size);
+ }
+
+ /**
+ * Creates a batch for processing a fixed list of file identifiers.
+ */
+ protected static function buildFixed(array $fids, bool $force, int $batch_size) {
+ $builder = new BatchBuilder();
+ return $builder
+ ->setTitle(\t('Performing checks on @count file(s)', ['@count' => count($fids)]))
+ ->setInitMessage(\t('Starting'))
+ ->setErrorMessage(\t('Batch has encountered an error'))
+ ->addOperation([static::class, 'processFixedList'], [
+ $fids,
+ $force,
+ $batch_size,
+ ])
+ ->setFinishCallback([static::class, 'finished'])
+ ->toArray();
+ }
+
+ /**
+ * Creates a batch for processing files that have periodic checks enabled.
+ */
+ public static function buildPeriodic(bool $force, int $batch_size) {
+ $sources = \Drupal::config(SettingsForm::CONFIG_NAME)->get(SettingsForm::SOURCES);
+ if (empty($sources)) {
+ throw new \InvalidArgumentException("No sources specified, check the modules configuration.");
+ }
+ $builder = new BatchBuilder();
+ foreach ($sources as $source) {
+ $builder->addOperation(
+ [static::class, 'processSource'],
+ [$source, $batch_size],
+ );
+ }
+ return $builder
+ ->setTitle(\t('Enumerating periodic checks from @count Source(s)', ['@count' => count($sources)]))
+ ->setInitMessage(\t('Starting'))
+ ->setErrorMessage(\t('Batch has encountered an error'))
+ ->addOperation([static::class, 'processPeriodic'], [$force, $batch_size])
+ ->setFinishCallback([static::class, 'finished'])
+ ->toArray();
+ }
+
+ /**
+ * Check the given files.
+ *
+ * @param int[] $fids
+ * A list of file identifiers.
+ * @param bool $force
+ * A flag to indicate if the check should be performed even if the time
+ * elapsed since the last check has not exceed the required threshold.
+ * @param int $batch_size
+ * The amount of files each time this process runs.
+ * @param array|object $context
+ * Context for operations.
+ */
+ public static function processFixedList(array $fids, bool $force, int $batch_size, &$context) {
+ $sandbox = &$context['sandbox'];
+ $results = &$context['results'];
+ if (!isset($sandbox['total'])) {
+ $sandbox['offset'] = 0;
+ $sandbox['total'] = count($fids);
+ $results['successful'] = 0;
+ $results['ignored'] = 0;
+ $results['skipped'] = 0;
+ $results['failed'] = 0;
+ $results['errors'] = [];
+ }
+ $chunk = array_slice($fids, $sandbox['offset'], $batch_size);
+ $end = min($sandbox['total'], $sandbox['offset'] + count($chunk));
+ $context['message'] = \t('Processing @start to @end of @total', [
+ '@start' => $sandbox['offset'],
+ '@end' => $end,
+ '@total' => $sandbox['total'],
+ ]);
+ $files = \Drupal::service('entity_type.manager')->getStorage('file')->loadMultiple($chunk);
+ // It is possible for non existing fids to be listed in $chunk, in such
+ // cases this is ignored.
+ $results['ignored'] += count(array_diff($chunk, array_keys($files)));
+ static::check($files, $force, $results);
+ $sandbox['offset'] = $end;
+ $context['finished'] = $sandbox['offset'] / $sandbox['total'];
+ }
+
+ /**
+ * Enable periodic checks on files as returned by the give source view.
+ */
+ public static function processSource(string $source, $batch_size, &$context) {
+ $results = &$context['results'];
+ // Do not track success/failure on processing source, as that is done for
+ // checks only. Errors however do get passed though.
+ if (!isset($results['errors'])) {
+ $results['errors'] = [];
+ }
+
+ /** @var \Drupal\dgi_fixity\FixityCheckServiceInterface $fixity */
+ $fixity = \Drupal::service('dgi_fixity.fixity_check');
+ $view = $fixity->source($source, $batch_size);
+ $view->execute();
+ // Only processes those which have not already enabled periodic checks.
+ foreach ($view->result as $row) {
+ try {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $check */
+ $check = $view->field['periodic']->getEntity($row);
+ $check->setPeriodic(TRUE);
+ $check->save();
+ }
+ catch (\Exception $e) {
+ $results['errors'][] = \t('Encountered an exception: @exception', [
+ '@exception' => $e,
+ ]);
+ // In practice exceptions in this case shouldn't arise, but if they do
+ // exit to prevent an infinite loop by exiting the operation.
+ $context['finished'] = 1;
+ return;
+ }
+ }
+ // End when we have exhausted all inputs.
+ $context['finished'] = count($view->result) == 0;
+ }
+
+ /**
+ * Checks all files which have enabled periodic fixity checks.
+ *
+ * @param bool $force
+ * A flag to indicate if the check should be performed even if the time
+ * elapsed since the last check has not exceed the required threshold.
+ * @param int $batch_size
+ * The amount of files each time this process runs.
+ * @param array|object $context
+ * Context for operations.
+ */
+ public static function processPeriodic(bool $force, int $batch_size, &$context) {
+ /** @var \Drupal\dgi_fixity\FixityCheckStorageInterface $storage */
+ $storage = \Drupal::entityTypeManager()->getStorage('fixity_check');
+
+ $sandbox = &$context['sandbox'];
+ $results = &$context['results'];
+ if (!isset($sandbox['offset'])) {
+ $sandbox['offset'] = 0;
+ $sandbox['remaining'] = $storage->countPeriodic();
+ $results['successful'] = 0;
+ $results['ignored'] = 0;
+ $results['skipped'] = 0;
+ $results['failed'] = 0;
+ $results['errors'] = $results['errors'] ?? [];
+ }
+
+ $files = $storage->getPeriodic($sandbox['offset'], $batch_size);
+ $end = min($sandbox['total'], $sandbox['offset'] + count($files));
+ $context['message'] = \t('Processing @start to @end', [
+ '@start' => $sandbox['offset'],
+ '@end' => $end,
+ ]);
+ static::check($files, $force, $results);
+ $sandbox['offset'] = $end;
+
+ $remaining = $storage->countPeriodic();
+ $progress_halted = $sandbox['remaining'] == $remaining;
+ $sandbox['remaining'] = $remaining;
+
+ // End when we have exhausted all inputs or progress has halted.
+ $context['finished'] = empty($files) || $progress_halted;
+ }
+
+ /**
+ * Performs a fixity check on the given list of files.
+ *
+ * @param \Drupal\file\FileInterface[] $files
+ * A list of file identifiers.
+ * @param bool $force
+ * A flag to indicate if the check should be performed even if the time
+ * elapsed since the last check has not exceed the required threshold.
+ * @param array &$results
+ * An associative array with the results of the fixity checks
+ * - missing: The number of file identifiers for which no files exist.
+ * - successful: The number of files that were successfully checked.
+ * - errors: A list of error messages if any occurred.
+ */
+ protected static function check(array $files, bool $force, array &$results) {
+ /** @var \Drupal\dgi_fixity\FixityCheckServiceInterface $fixity */
+ $fixity = \Drupal::service('dgi_fixity.fixity_check');
+ foreach ($files as $file) {
+ try {
+ $result = $fixity->check($file, $force);
+ if ($result instanceof FixityCheckInterface) {
+ if ($result->passed()) {
+ $results['successful']++;
+ }
+ }
+ else {
+ // The check was not performed as the time elapsed since the last
+ // check did not exceed the required threshold.
+ $results['skipped']++;
+ }
+ }
+ catch (\Exception $e) {
+ $results['failed']++;
+ $results['errors'][] = \t('Encountered an exception: @exception', [
+ '@exception' => $e,
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Batch Finished callback.
+ *
+ * @param bool $success
+ * Success of the operation.
+ * @param array $results
+ * Array of results for post processing.
+ * @param array $operations
+ * Array of operations.
+ */
+ public static function finished($success, array $results, array $operations) {
+ $messenger = \Drupal::messenger();
+ $messenger->addStatus(new PluralTranslatableMarkup(
+ $results['successful'] + $results['ignored'] + $results['skipped'] + $results['failed'],
+ 'Processed @count item in total.',
+ 'Processed @count items in total.'
+ ));
+ $messenger->addStatus(new PluralTranslatableMarkup(
+ $results['successful'],
+ '@count was successful.',
+ '@count were successful.',
+ ));
+ $messenger->addStatus(new PluralTranslatableMarkup(
+ $results['ignored'],
+ '@count was ignored.',
+ '@count were ignored.',
+ ));
+ $messenger->addStatus(new PluralTranslatableMarkup(
+ $results['skipped'],
+ '@count was skipped.',
+ '@count were skipped.',
+ ));
+ $messenger->addStatus(\t(
+ '@count failed.', ['@count' => $results['failed']]
+ ));
+ $error_count = count($results['errors']);
+ if ($error_count > 0) {
+ $messenger->addMessage(new PluralTranslatableMarkup(
+ $error_count,
+ '@count error occurred.',
+ '@count errors occurred.',
+ ));
+ foreach ($results['errors'] as $error) {
+ $messenger->addError($error);
+ }
+ }
+ }
+
+}
diff --git a/src/FixityCheckBatchGenerate.php b/src/FixityCheckBatchGenerate.php
new file mode 100644
index 0000000..0bfa5ab
--- /dev/null
+++ b/src/FixityCheckBatchGenerate.php
@@ -0,0 +1,117 @@
+get(SettingsForm::BATCH_SIZE);
+ }
+ $builder = new BatchBuilder();
+ return $builder
+ ->setTitle(\t('Generating Fixity Checks for previously created files'))
+ ->setInitMessage(\t('Starting'))
+ ->setErrorMessage(\t('Batch has encountered an error'))
+ ->addOperation([static::class, 'generate'], [$batch_size])
+ ->setFinishCallback([static::class, 'finished'])
+ ->toArray();
+ }
+
+ /**
+ * Generates fixity_check entity for previously created files.
+ *
+ * @param int $batch_size
+ * The number of of files to process at a time.
+ * @param array|object $context
+ * Context for operations.
+ */
+ public static function generate($batch_size, &$context) {
+ /** @var \Drupal\dgi_fixity\FixityCheckStorageInterface $storage */
+ $storage = \Drupal::entityTypeManager()->getStorage('fixity_check');
+
+ $sandbox = &$context['sandbox'];
+ $results = &$context['results'];
+ if (!isset($results['successful'])) {
+ $results['successful'] = 0;
+ $results['failed'] = 0;
+ $results['errors'] = [];
+ $sandbox['remaining'] = $storage->countMissing();
+ }
+
+ $files = $storage->getMissing(0, $batch_size);
+ foreach ($files as $file) {
+ $check = $storage->create(['file' => $file->id()]);
+ try {
+ $check->save();
+ $results['successful']++;
+ }
+ catch (\Exception $e) {
+ $results['failed']++;
+ $results['errors'][] = \t('Encountered an exception: @exception', [
+ '@exception' => $e,
+ ]);
+ }
+ }
+
+ $remaining = $storage->countMissing();
+ $progress_halted = $sandbox['remaining'] == $remaining;
+ $sandbox['remaining'] = $remaining;
+
+ // End when we have exhausted all inputs or progress has halted.
+ $context['finished'] = empty($files) || $progress_halted;
+ }
+
+ /**
+ * Batch Finished callback.
+ *
+ * @param bool $success
+ * Success of the operation.
+ * @param array $results
+ * Array of results for post processing.
+ * @param array $operations
+ * Array of operations.
+ */
+ public static function finished($success, array $results, array $operations) {
+ $messenger = \Drupal::messenger();
+ $messenger->addStatus(new PluralTranslatableMarkup(
+ $results['successful'] + $results['failed'],
+ 'Processed @count item in total.',
+ 'Processed @count items in total.'
+ ));
+ $messenger->addStatus(new PluralTranslatableMarkup(
+ $results['successful'],
+ '@count was successful.',
+ '@count were successful.',
+ ));
+ $messenger->addStatus(\t(
+ '@count failed.', ['@count' => $results['failed']]
+ ));
+ $error_count = count($results['errors']);
+ if ($error_count > 0) {
+ $messenger->addMessage(new PluralTranslatableMarkup(
+ $error_count,
+ '@count error occurred.',
+ '@count errors occurred.',
+ ));
+ foreach ($results['errors'] as $error) {
+ $messenger->addError($error);
+ }
+ }
+ }
+
+}
diff --git a/src/FixityCheckInterface.php b/src/FixityCheckInterface.php
new file mode 100644
index 0000000..aedd833
--- /dev/null
+++ b/src/FixityCheckInterface.php
@@ -0,0 +1,237 @@
+ [
+ 'label' => 'Undefined',
+ 'singular' => '@count check is undefined',
+ 'plural' => '@count checks are undefined',
+ 'passed' => FALSE,
+ ],
+ self::STATE_MATCHES => [
+ 'label' => 'Matched recorded values',
+ 'singular' => '@count check match the recorded checksum(s)',
+ 'plural' => '@count checks matched the recorded checksum(s)',
+ 'passed' => TRUE,
+ ],
+ self::STATE_MISMATCHES => [
+ 'label' => 'Did not match recorded values',
+ 'singular' => '@count check did not match the recorded checksum(s)',
+ 'plural' => '@count checks did not match the recorded checksum(s)',
+ 'passed' => FALSE,
+ ],
+ self::STATE_MISSING => [
+ 'label' => 'Could not be performed: File missing',
+ 'singular' => '@count file is missing and could not be checked',
+ 'plural' => '@count files are missing and could not be checked',
+ 'passed' => FALSE,
+ ],
+ self::STATE_NO_CHECKSUM => [
+ 'label' => 'Could not be performed: Missing recorded checksum(s)',
+ 'singular' => '@count file is missing a recorded checksum',
+ 'plural' => '@count files are missing recorded checksums',
+ 'passed' => FALSE,
+ ],
+ self::STATE_GENERATION_FAILED => [
+ 'label' => 'Could not be performed: Could not generate checksum(s)',
+ 'singular' => '@count check could not generate a checksum',
+ 'plural' => '@count checks could not generate checksums',
+ 'passed' => FALSE,
+ ],
+ ];
+
+ /**
+ * Gets the file this check was performed against.
+ *
+ * @return \Drupal\file\Entity\File
+ * The file associated with this check or NULL if not set.
+ */
+ public function getFile(): ?File;
+
+ /**
+ * Sets the state of the check.
+ *
+ * @param \Drupal\file\Entity\File $file
+ * The state of the check.
+ *
+ * @return $this
+ */
+ public function setFile(File $file): FixityCheckInterface;
+
+ /**
+ * Gets the state of the check.
+ *
+ * @return int
+ * The state of the check.
+ */
+ public function getState(): int;
+
+ /**
+ * Sets the state of the check.
+ *
+ * @param int $state
+ * The state of the check.
+ *
+ * @return $this
+ *
+ * @throws \InvalidArgumentException
+ * If $state is not valid.
+ */
+ public function setState(int $state): FixityCheckInterface;
+
+ /**
+ * Gets the human readable representation of the state.
+ *
+ * @return string
+ * The state.
+ */
+ public function getStateLabel(): string;
+
+ /**
+ * Gets the given property of the given state if defined.
+ *
+ * @param int $state
+ * The state of whose properties are fetched.
+ * @param string $property
+ * The property to get.
+ *
+ * @return mixed|null
+ * The property if defined otherwise NULL.
+ *
+ * @see \Drupal\dgi_fixity\FixityCheckInterface::STATES
+ */
+ public static function getStateProperty(int $state, string $property);
+
+ /**
+ * Checks if the check passed.
+ *
+ * @see \Drupal\dgi_fixity\FixityCheckInterface::STATES
+ *
+ * @return bool
+ * TRUE if the generated checksums match the recorded values, FALSE
+ * otherwise.
+ */
+ public function passed(): bool;
+
+ /**
+ * Gets the timestamp of when the check was performed.
+ *
+ * @return int
+ * The timestamp of the check. 0 indicates the check was not performed.
+ */
+ public function getPerformed(): int;
+
+ /**
+ * Sets the timestamp of when the check was performed.
+ *
+ * @param int $performed
+ * The timestamp when the check was performed.
+ *
+ * @return $this
+ */
+ public function setPerformed(int $performed): FixityCheckInterface;
+
+ /**
+ * TRUE if this check was performed, FALSE otherwise.
+ *
+ * @return bool
+ * TRUE if this check was performed, FALSE otherwise.
+ */
+ public function wasPerformed(): bool;
+
+ /**
+ * Checks if periodic checks are enabled.
+ *
+ * @return int
+ * TRUE if periodic checks are enabled, FALSE otherwise.
+ */
+ public function getPeriodic(): bool;
+
+ /**
+ * Enable or disable periodic checks.
+ *
+ * @param bool $periodic
+ * TRUE to enable periodic checks, FALSE otherwise.
+ *
+ * @return $this
+ */
+ public function setPeriodic(bool $periodic): FixityCheckInterface;
+
+ /**
+ * Gets the timestamp of when the check was queued.
+ *
+ * @return int
+ * The timestamp when the check was queued.
+ * 0 indicates the check is not queued.
+ */
+ public function getQueued(): int;
+
+ /**
+ * Sets the timestamp of when the check was queued.
+ *
+ * @param int $queued
+ * The timestamp when queued.
+ *
+ * @return $this
+ */
+ public function setQueued(int $queued): FixityCheckInterface;
+
+ /**
+ * The cache tags associated with the audit display of this entity.
+ *
+ * @return string[]
+ * The cache tags.
+ */
+ public function getAuditCacheTags();
+
+}
diff --git a/src/FixityCheckListBuilder.php b/src/FixityCheckListBuilder.php
new file mode 100644
index 0000000..e0ccd07
--- /dev/null
+++ b/src/FixityCheckListBuilder.php
@@ -0,0 +1,123 @@
+dateFormatter = $date_formatter;
+ $this->renderer = $renderer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+ $entity_type_manager = $container->get('entity_type.manager');
+ return new static(
+ $entity_type,
+ $entity_type_manager->getStorage($entity_type->id()),
+ $container->get('date.formatter'),
+ $container->get('renderer'),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildHeader() {
+ $header = [];
+ $header += [
+ 'link' => [
+ 'data' => $this->t('Check'),
+ ],
+ 'state' => [
+ 'data' => $this->t('State'),
+ ],
+ 'performed' => [
+ 'data' => $this->t('Performed'),
+ ],
+ ];
+ return $header + parent::buildHeader();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildRow(EntityInterface $entity) {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $entity */
+ $row = [
+ 'link' => $entity->toLink(),
+ ];
+
+ $row['state']['data'] = $entity->state->view([
+ 'label' => 'hidden',
+ 'type' => 'dgi_fixity_state',
+ ]);
+
+ // Use timestamp rather than timestamp_ago to allow for caching.
+ $row['performed']['data'] = $entity->wasPerformed() ?
+ $entity->performed->view([
+ 'label' => 'hidden',
+ 'type' => 'timestamp',
+ 'weight' => 1,
+ ]) :
+ ['#markup' => $this->t('never')];
+
+ return $row + parent::buildRow($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEntityIds() {
+ $query = $this->getStorage()->getQuery()
+ ->accessCheck(TRUE)
+ ->sort('performed', 'DESC');
+
+ // Only add the pager if a limit is specified.
+ if ($this->limit) {
+ $query->pager($this->limit);
+ }
+ return $query->execute();
+ }
+
+}
diff --git a/src/FixityCheckService.php b/src/FixityCheckService.php
new file mode 100644
index 0000000..104598d
--- /dev/null
+++ b/src/FixityCheckService.php
@@ -0,0 +1,402 @@
+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;
+ }
+
+}
diff --git a/src/FixityCheckServiceInterface.php b/src/FixityCheckServiceInterface.php
new file mode 100644
index 0000000..c719418
--- /dev/null
+++ b/src/FixityCheckServiceInterface.php
@@ -0,0 +1,136 @@
+entityTypeManager->getStorage('file');
+ $query = $this->database->select($storage->getBaseTable(), 'file_managed');
+ $query->leftJoin($this->baseTable, 'fixity_check', '[file_managed].[fid] = [fixity_check].[file]');
+ return $query
+ ->isNull('fixity_check.file')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMissing(int $offset, int $limit): array {
+ /** @var \Drupal\file\FileStorage $storage */
+ $storage = $this->entityTypeManager->getStorage('file');
+ $query = $this->database->select($storage->getBaseTable(), 'file_managed');
+ $query->fields('file_managed', ['fid']);
+ $query->leftJoin($this->baseTable, 'fixity_check', '[file_managed].[fid] = [fixity_check].[file]');
+ $ids = $query
+ ->isNull('fixity_check.file')
+ ->orderBy('file_managed.fid')
+ ->range($offset, $limit)
+ ->execute()
+ ->fetchCol();
+ return $storage->loadMultiple($ids);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function clearPeriodic() {
+ $this->database
+ ->update($this->baseTable)
+ ->fields([
+ 'periodic' => 0,
+ ])
+ ->execute();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function countPeriodic(): int {
+ return $this->database
+ ->select($this->baseTable, 'c')
+ ->condition('c.periodic', 1)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPeriodic(int $offset, int $limit): array {
+ /** @var \Drupal\file\FileStorage $storage */
+ $storage = $this->entityTypeManager->getStorage('file');
+ $ids = $this->database
+ ->select($this->baseTable, 'c')
+ ->condition('c.periodic', 1)
+ ->fields('c', ['file'])
+ ->range($offset, $limit)
+ ->orderBy('id')
+ ->execute()
+ ->fetchCol();
+ return $storage->loadMultiple($ids);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function queue(int $queued, int $threshold, int $limit) {
+ // Do not over-saturate the queue.
+ // 10x the limit is the max we allow to be queued at a time.
+ $queue = \Drupal::queue('dgi_fixity.fixity_check');
+ if ($queue->numberOfItems() > (10 * $limit)) {
+ return;
+ }
+
+ $query = $this->database->select($this->baseTable, 'c');
+ // Either never performed or out of date.
+ $performed = $query->orConditionGroup()
+ ->condition('c.performed', 0, '=')
+ ->condition('c.performed', $threshold, '<=');
+
+ // Only those which have enabled periodic checking, and are not already
+ // queued.
+ $ids = $query
+ ->fields('c', ['id'])
+ ->condition('c.periodic', 1)
+ ->condition('c.queued', 0)
+ ->condition($performed)
+ ->range(0, $limit)
+ ->execute()
+ ->fetchCol();
+
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $check */
+ foreach ($this->doLoadMultiple($ids) as $check) {
+ // Queue checks for processing.
+ if ($queue->createItem($check)) {
+ // Add timestamp to avoid queueing item more than once.
+ $check->setQueued($queued);
+ $check->save();
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function dequeue(int $queued) {
+ $this->database
+ ->update($this->baseTable)
+ ->fields([
+ 'queued' => 0,
+ ])
+ ->condition('queued', 0, '<>')
+ ->condition('queued', $queued, '<')
+ ->execute();
+ }
+
+}
diff --git a/src/FixityCheckStorageInterface.php b/src/FixityCheckStorageInterface.php
new file mode 100644
index 0000000..10bbcec
--- /dev/null
+++ b/src/FixityCheckStorageInterface.php
@@ -0,0 +1,79 @@
+getName();
+
+ if ($table_name == $this->storage->getBaseTable()) {
+ switch ($field_name) {
+ case 'file':
+ $this->addSharedTableFieldUniqueKey($storage_definition, $schema, TRUE);
+ $this->addSharedTableFieldForeignKey($storage_definition, $schema, 'file_managed', 'fid');
+ break;
+
+ case 'state':
+ case 'performed':
+ case 'periodic':
+ case 'queued':
+ $this->addSharedTableFieldIndex($storage_definition, $schema, TRUE);
+ break;
+
+ }
+ }
+
+ return $schema;
+ }
+
+}
diff --git a/src/FixityCheckViewsData.php b/src/FixityCheckViewsData.php
new file mode 100644
index 0000000..53c4841
--- /dev/null
+++ b/src/FixityCheckViewsData.php
@@ -0,0 +1,44 @@
+t('Fixity Check');
+ $data['fixity_check']['table']['join'] = [
+ 'fixity_check_revision' => [
+ 'left_field' => 'revision_id',
+ 'field' => 'revision_id',
+ ],
+ 'file_managed' => [
+ 'left_field' => 'fid',
+ 'field' => 'file',
+ ],
+ ];
+ $data['fixity_check_revision']['table']['wizard_id'] = 'fixity_check_revision';
+ $data['fixity_check_revision']['table']['group'] = $this->t('Fixity Check revision');
+ $data['fixity_check_revision']['table']['join'] = [
+ 'fixity_check' => [
+ 'left_field' => 'revision_id',
+ 'field' => 'revision_id',
+ ],
+ 'file_managed' => [
+ 'left_field' => 'fid',
+ 'field' => 'file',
+ ],
+ ];
+ return $data;
+ }
+
+}
diff --git a/src/Form/BatchForm.php b/src/Form/BatchForm.php
new file mode 100644
index 0000000..e44bfff
--- /dev/null
+++ b/src/Form/BatchForm.php
@@ -0,0 +1,62 @@
+ 'markup',
+ '#markup' => $this->t('Submitting this form will perform fixity checks against all files with periodic checks enabled.
This will automatically be done via cron, but it can be performed manually here.'),
+ ];
+ $form['force'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Skip time elapsed check'),
+ '#description' => $this->t('If enabled, all files will be checked without regard to the time elapsed since the previous check was performed on the selected file.'),
+ '#default' => FALSE,
+ ];
+ $form['actions'] = [
+ '#type' => 'actions',
+ 'submit' => [
+ '#type' => 'submit',
+ '#value' => $this->t('Check'),
+ '#button_type' => 'primary',
+ ],
+ ];
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $force = boolval($form_state->getValue('force'));
+ try {
+ $batch = FixityCheckBatchCheck::build(NULL, $force);
+ batch_set($batch);
+ }
+ catch (\InvalidArgumentException $e) {
+ $this->messenger()->addError($e->getMessage());
+ }
+ }
+
+}
diff --git a/src/Form/CheckForm.php b/src/Form/CheckForm.php
new file mode 100644
index 0000000..03dfc30
--- /dev/null
+++ b/src/Form/CheckForm.php
@@ -0,0 +1,160 @@
+fixity = $fixity;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity.repository'),
+ $container->get('entity_type.bundle.info'),
+ $container->get('datetime.time'),
+ $container->get('dgi_fixity.fixity_check')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entity_type_id) {
+ // Support checking entity types from which we can determine the associated
+ // fixity_check.
+ $entity = parent::getEntityFromRouteMatch($route_match, $entity_type_id);
+ // Allow the original to affect the redirect url. For example return to the
+ // media's audit page rather than the fixity_check's audit page.
+ $this->sourceEntity = $entity;
+ return ($entity instanceof FixityCheckInterface) ?
+ $entity :
+ $this->fixity->fromEntity($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $entity */
+ $entity = $this->getEntity();
+ if ($entity->wasPerformed()) {
+ $scheduled = $this->fixity->scheduled($entity);
+ if ($scheduled) {
+ return $this->t('
+ Latest Result: %state
+ Last Performed: %performed
+ Next Scheduled: %scheduled
+ ', [
+ '%state' => $entity->getStateLabel(),
+ '%performed' => date(DATE_RFC7231, $entity->getPerformed()),
+ '%scheduled' => date(DATE_RFC7231, $scheduled),
+ ]);
+ }
+ return $this->t('
+ Latest Result: %state
+ Last Performed: %performed
+ ', [
+ '%state' => $entity->getStateLabel(),
+ '%performed' => date(DATE_RFC7231, $entity->getPerformed()),
+ '%scheduled' => date(DATE_RFC7231, $scheduled),
+ ]);
+ }
+ return $this->t('No prior check has been performed.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getQuestion() {
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $entity */
+ $entity = $this->getEntity();
+ return $this->t('Are you sure you want to perform a check on %label?', [
+ '%label' => $this->getEntity()->label(),
+ '%performed' => date(DATE_RFC7231, $entity->getPerformed()),
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl() {
+ return $this->sourceEntity->toUrl('fixity-audit');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $entity */
+ $entity = $this->getEntity();
+ /** @var \Drupal\dgi_fixity\FixityCheckInterface $check */
+ $check = $this->fixity->check($entity->getFile(), TRUE);
+ $this->setEntity($check);
+ unset($entity);
+
+ $message = $this->t('The @entity-type %label: %state.', [
+ '@entity-type' => $check->getEntityType()->getSingularLabel(),
+ '%label' => $check->toLink()->toString(),
+ '%state' => $check->getStateLabel(),
+ ]);
+
+ if ($check->passed()) {
+ $this->messenger()->addStatus($message);
+ }
+ else {
+ $this->messenger()->addError($message);
+ }
+
+ // If no destination set return to the fixity check audit page.
+ $form_state->setRedirectUrl($this->getCancelUrl());
+ }
+
+}
diff --git a/src/Form/GenerateForm.php b/src/Form/GenerateForm.php
new file mode 100644
index 0000000..6d0dd67
--- /dev/null
+++ b/src/Form/GenerateForm.php
@@ -0,0 +1,85 @@
+entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'dgi_fixity_generate_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ /** @var \Drupal\dgi_fixity\FixityCheckStorageInterface $storage */
+ $storage = $this->entityTypeManager->getStorage('fixity_check');
+ $form['info'] = [
+ '#type' => 'markup',
+ '#markup' => $this->t('
+
Submitting this form generate fixity checks for @count files.
+Generally this should only be required when the module is first installed.
+ ', + ['@count' => $storage->countMissing()] + ), + ]; + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => $this->t('Generate'), + '#button_type' => 'primary', + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $batch = FixityCheckBatchGenerate::build(); + batch_set($batch); + } + +} diff --git a/src/Form/RevisionDeleteForm.php b/src/Form/RevisionDeleteForm.php new file mode 100644 index 0000000..c96a261 --- /dev/null +++ b/src/Form/RevisionDeleteForm.php @@ -0,0 +1,132 @@ +storage = $storage; + $this->dateFormatter = $date_formatter; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager')->getStorage('fixity_check'), + $container->get('date.formatter') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'dgi_fixity_revision_delete_confirm'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->t('Are you sure you want to delete the revision from %revision-date?', [ + '%revision-date' => $this->dateFormatter->format( + $this->revision->getPerformed() + ), + ]); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.fixity_check.fixity_audit', [ + 'fixity_check' => $this->revision->id(), + ]); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, FixityCheckInterface $fixity_check_revision = NULL) { + $this->revision = $fixity_check_revision; + $form = parent::buildForm($form, $form_state); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->storage->deleteRevision($this->revision->getRevisionId()); + + $this->logger('content')->notice('Fixity Check: deleted %title revision %revision.', [ + '%title' => $this->revision->label(), + '%revision' => $this->revision->getRevisionId(), + ]); + $this->messenger()->addStatus( + $this->t('Revision from %revision-date of %title has been deleted.', + [ + '%revision-date' => $this->dateFormatter->format( + $this->revision->getPerformed() + ), + '%title' => $this->revision->label(), + ] + )); + $form_state->setRedirect('entity.fixity_check.fixity_audit', [ + 'fixity_check' => $this->revision->id(), + ]); + } + +} diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php new file mode 100644 index 0000000..64cd4b6 --- /dev/null +++ b/src/Form/SettingsForm.php @@ -0,0 +1,305 @@ +entityTypeManager = $entity_type_manager; + $this->state = $state; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('entity_type.manager'), + $container->get('state'), + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'dgi_fixity_settings_form'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return [ + static::CONFIG_NAME, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $config = $this->config(static::CONFIG_NAME); + $displays = Views::getApplicableViews('entity_reference_display'); + $view_storage = $this->entityTypeManager->getStorage('view'); + $sources = []; + foreach ($displays as $data) { + [$view_id, $display_id] = $data; + /** @var \Drupal\views\Entity\View $view */ + $view = $view_storage->load($view_id); + $tag = $view->getExecutable()->storage->get('tag'); + // Only list views tagged with 'fixity'. + if (in_array('fixity', Tags::explode($tag))) { + $entity_type = $view->getExecutable()->getBaseEntityType(); + // Only use views that return 'file' entities, as the batch and workers + // dynamically filter the view by relating it to 'fixity_checks' that + // do not yet have their 'periodic' flag set to TRUE. + if ($entity_type && $entity_type->id() == 'file') { + $display = $view->get('display'); + $set_name = $view_id . ':' . $display_id; + $sources[$set_name] = $display[$display_id]['display_title'] . ' (' . $set_name . ')'; + } + } + } + + $form['checks'] = [ + '#type' => 'details', + '#title' => $this->t('Fixity Checks'), + '#open' => TRUE, + static::SOURCES => [ + '#title' => $this->t('File Selection'), + '#description' => $this->t(' +Select one or more Views. Whose results are used to determine which files have periodic checks enabled according to the schedule below.
+Only File Entity Views are supported.
+Only entity_reference or entity_reference_revisions displays are supported.
+If selecting multiple Views ideally they should not overlap, if only for the sake of efficiency.
+Views must be have an administrative tag fixity to appear in this list.
+ '), + '#type' => 'checkboxes', + '#options' => $sources, + '#default_value' => $config->get(static::SOURCES) ?: [], + ], + static::THRESHOLD => [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Time elapsed'), + '#description' => $this->t(' +Time threshold is relative to "now". For example "-1 month" would prevent any checks that occurred less than a month ago.
+Check Relative Formats for acceptable values
+ '), + '#default_value' => $config->get(static::THRESHOLD) ?: '-1 month', + '#element_validate' => [ + [$this, 'validateThreshold'], + ], + ], + static::BATCH_SIZE => [ + '#type' => 'number', + '#required' => TRUE, + '#title' => $this->t('Batch size'), + '#description' => $this->t(' +Set how many files will be processed at once when performing a batch / cron job
+ '), + '#default_value' => 100, + ], + ]; + + // Default to the admin user if not given. + $user = $config->get(static::NOTIFY_USER); + $user = is_int($user) ? + $this->entityTypeManager->getStorage('user')->load($user) : + $this->entityTypeManager->getStorage('user')->load(1); + + $notification_threshold = $config->get(static::NOTIFY_USER_THRESHOLD) ?: '-1 week'; + $last_notification = $this->state->get(static::STATE_LAST_NOTIFICATION); + if ($last_notification !== NULL) { + $next_notification = $last_notification + (time() - strtotime($notification_threshold)); + } + + $form['notify'] = [ + '#type' => 'details', + '#title' => $this->t('Notifications'), + '#description' => $this->t('Notifications are sent by email to the selected user.'), + '#open' => TRUE, + static::NOTIFY_STATUS => [ + '#type' => 'select', + '#title' => $this->t('Notification Status'), + '#description' => $this->t(' +Choose under what conditions should notifications be sent to the selected user.
+ '), + '#options' => [ + static::NOTIFY_STATUS_NEVER => $this->t('Never'), + static::NOTIFY_STATUS_ALWAYS => $this->t('Always'), + static::NOTIFY_STATUS_ERROR => $this->t('Only if error'), + ], + '#default_value' => $config->get(static::NOTIFY_STATUS) ?? static::NOTIFY_STATUS_ERROR, + ], + static::NOTIFY_USER => [ + '#type' => 'entity_autocomplete', + '#required' => TRUE, + '#title' => $this->t('User'), + '#description' => $this->t(' +The user to be notified should one or more fixity checks fail.
+The user will be notified by email.
+ '), + '#target_type' => 'user', + '#default_value' => $user, + '#selection_settings' => [ + 'include_anonymous' => FALSE, + ], + ], + static::NOTIFY_USER_THRESHOLD => [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Time elapsed'), + '#description' => $this->t(' +Time threshold is relative to "now". For example "-1 week" would mean a week must pass between notifications.
+Check Relative Formats for acceptable values
+ '), + '#default_value' => $notification_threshold, + '#element_validate' => [ + [$this, 'validateThreshold'], + ], + ], + 'last' => $last_notification ? + [ + '#type' => 'details', + '#title' => $this->t('Last notification'), + '#description' => $this->t(' +The last notification was sent on %last.
+At earliest the next message can be sent %next.
+ ', [ + '%last' => date(DATE_RFC7231, $last_notification), + '%next' => date(DATE_RFC7231, $next_notification), + ] + ), + static::STATE_LAST_NOTIFICATION => [ + '#type' => 'button', + '#value' => $this->t('Reset'), + '#limit_validation_errors' => [], + '#executes_submit_callback' => TRUE, + '#submit' => [ + [$this, 'resetLastNotification'], + ], + ], + ] : NULL, + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * Element validate callback; validate the threshold is valid. + */ + public function validateThreshold(array $element, FormStateInterface $form_state, array $form) { + $value = $form_state->getValue($element['#parents']); + if (strtotime($value) === FALSE) { + $form_state->setError($element, $this->t('The given threshold is not valid.')); + } + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $value = $form_state->getValue(static::NOTIFY_USER); + /** @var \Drupal\user\Entity\User $user */ + $user = is_numeric($value) ? + $this->entityTypeManager->getStorage('user')->load($value) : + NULL; + if ($user === NULL) { + // Just a precaution the default form element validation should + // catch this case anyways. + $form_state->setError($form['notify'][static::NOTIFY_USER], $this->t('The given user does not exist.')); + } + elseif ($user->getEmail() === NULL) { + $form_state->setError($form['notify'][static::NOTIFY_USER], $this->t('The given user does not have an email address associated with their account.')); + } + } + + /** + * Resets the stored last notification. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function resetLastNotification(array &$form, FormStateInterface $form_state) { + $this->state->delete(static::STATE_LAST_NOTIFICATION); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $values = $form_state->getValues(); + $config = $this->config(static::CONFIG_NAME); + $sources = array_keys(array_filter($values[static::SOURCES])); + $config + ->set(static::SOURCES, array_combine($sources, $sources)) + ->set(static::THRESHOLD, $values[static::THRESHOLD]) + ->set(static::BATCH_SIZE, $values[static::BATCH_SIZE]) + ->set(static::NOTIFY_STATUS, $values[static::NOTIFY_STATUS]) + ->set(static::NOTIFY_USER, $values[static::NOTIFY_USER]) + ->set(static::NOTIFY_USER_THRESHOLD, $values[static::NOTIFY_USER_THRESHOLD]) + ->save(); + parent::submitForm($form, $form_state); + } + +} diff --git a/src/Plugin/Action/CheckAction.php b/src/Plugin/Action/CheckAction.php new file mode 100644 index 0000000..b4958d3 --- /dev/null +++ b/src/Plugin/Action/CheckAction.php @@ -0,0 +1,26 @@ +getCheck($entity); + if ($check) { + $this->fixity->check($check->getFile(), TRUE); + } + } + +} diff --git a/src/Plugin/Action/Derivative/FixityCheckActionDeriver.php b/src/Plugin/Action/Derivative/FixityCheckActionDeriver.php new file mode 100644 index 0000000..dbb5abd --- /dev/null +++ b/src/Plugin/Action/Derivative/FixityCheckActionDeriver.php @@ -0,0 +1,62 @@ +entityTypeManager = $entity_type_manager; + $this->stringTranslation = $string_translation; + $this->fixity = $fixity; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager'), + $container->get('string_translation'), + $container->get('dgi_fixity.fixity_check'), + ); + } + + /** + * {@inheritdoc} + */ + protected function isApplicable(EntityTypeInterface $entity_type) { + $supported_entity_types = array_merge(['fixity_check'], $this->fixity->fromEntityTypes()); + return in_array($entity_type->id(), $supported_entity_types); + } + +} diff --git a/src/Plugin/Action/FixityCheckActionBase.php b/src/Plugin/Action/FixityCheckActionBase.php new file mode 100644 index 0000000..afc8ec4 --- /dev/null +++ b/src/Plugin/Action/FixityCheckActionBase.php @@ -0,0 +1,85 @@ +fixity = $fixity; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('dgi_fixity.fixity_check'), + ); + } + + /** + * Gets the related fixity_check entity for the given entity if possible. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The related entity. + * + * @return \Drupal\dgi_fixity\FixityCheckInterface + * The related fixity_check if found or NULL. + */ + protected function getCheck(EntityInterface $entity): ?FixityCheckInterface { + return $entity instanceof FixityCheckInterface ? + $entity : + $this->fixity->fromEntity($entity); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + // If it exists and the user has permission to administer fixity checks. + $result = $this->getCheck($object) && $account->hasPermission('administer fixity checks') ? + AccessResult::allowed() : + AccessResult::forbidden(); + + return $return_as_object ? $result : $result->isAllowed(); + } + +} diff --git a/src/Plugin/Action/PeriodicDisableAction.php b/src/Plugin/Action/PeriodicDisableAction.php new file mode 100644 index 0000000..e0150ef --- /dev/null +++ b/src/Plugin/Action/PeriodicDisableAction.php @@ -0,0 +1,27 @@ +getCheck($entity); + if ($check) { + $check->setPeriodic(FALSE); + $check->save(); + } + } + +} diff --git a/src/Plugin/Action/PeriodicEnableAction.php b/src/Plugin/Action/PeriodicEnableAction.php new file mode 100644 index 0000000..d2bb035 --- /dev/null +++ b/src/Plugin/Action/PeriodicEnableAction.php @@ -0,0 +1,27 @@ +getCheck($entity); + if ($check) { + $check->setPeriodic(TRUE); + $check->save(); + } + } + +} diff --git a/src/Plugin/Derivative/FixityCheckLocalTasks.php b/src/Plugin/Derivative/FixityCheckLocalTasks.php new file mode 100644 index 0000000..acd41cb --- /dev/null +++ b/src/Plugin/Derivative/FixityCheckLocalTasks.php @@ -0,0 +1,80 @@ +stringTranslation = $string_translation; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('string_translation'), + $container->get('entity_type.manager'), + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) { + if ($entity_type->hasLinkTemplate('fixity-audit')) { + $this->derivatives["$entity_type_id.fixity"] = [ + 'route_name' => "entity.{$entity_type_id}.fixity_audit", + 'title' => $this->t('Fixity'), + 'base_route' => "entity.{$entity_type_id}.canonical", + 'weight' => 10, + ]; + $this->derivatives["$entity_type_id.fixity.audit"] = [ + 'route_name' => "entity.$entity_type_id.fixity_audit", + 'title' => $this->t('Audit'), + 'parent_id' => "dgi_fixity.entities:$entity_type_id.fixity", + 'weight' => 10, + ]; + if ($entity_type->hasLinkTemplate('fixity-check')) { + $this->derivatives["$entity_type_id.fixity.check"] = [ + 'route_name' => "entity.$entity_type_id.fixity_check", + 'title' => $this->t('Check'), + 'parent_id' => "dgi_fixity.entities:$entity_type_id.fixity", + 'weight' => 10, + ]; + } + } + } + return $this->derivatives; + } + +} diff --git a/src/Plugin/Field/FieldFormatter/FileReferenceFormatter.php b/src/Plugin/Field/FieldFormatter/FileReferenceFormatter.php new file mode 100644 index 0000000..319a498 --- /dev/null +++ b/src/Plugin/Field/FieldFormatter/FileReferenceFormatter.php @@ -0,0 +1,47 @@ +getEntitiesToView($items, $langcode) as $delta => $entity) { + $label = $entity->label(); + $uri = $entity->createFileUrl(FALSE); + if (isset($uri)) { + $elements[$delta] = [ + '#type' => 'link', + '#title' => $label, + '#url' => Url::fromUri($uri), + ]; + } + else { + $elements[$delta] = ['#plain_text' => $label]; + } + $elements[$delta]['#cache']['tags'] = $entity->getCacheTags(); + } + return $elements; + } + +} diff --git a/src/Plugin/Field/FieldFormatter/StateFormatter.php b/src/Plugin/Field/FieldFormatter/StateFormatter.php new file mode 100644 index 0000000..47f69d3 --- /dev/null +++ b/src/Plugin/Field/FieldFormatter/StateFormatter.php @@ -0,0 +1,33 @@ + $item) { + $elements[$delta] = ['#markup' => FixityCheck::getStateProperty($item->value, 'label')]; + } + return $elements; + } + +} diff --git a/src/Plugin/QueueWorker/FixityCheckWorker.php b/src/Plugin/QueueWorker/FixityCheckWorker.php new file mode 100644 index 0000000..2bc09b5 --- /dev/null +++ b/src/Plugin/QueueWorker/FixityCheckWorker.php @@ -0,0 +1,68 @@ +fixity = $fixity; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('dgi_fixity.fixity_check'), + ); + } + + /** + * {@inheritdoc} + */ + public function processItem($data) { + if ($data instanceof FixityCheckInterface) { + /** @var \Drupal\dgi_fixity\FixityCheckInterface $data */ + $this->fixity->check($data->getFile()); + } + } + +} diff --git a/src/Plugin/QueueWorker/ProcessSourceWorker.php b/src/Plugin/QueueWorker/ProcessSourceWorker.php new file mode 100644 index 0000000..77e5266 --- /dev/null +++ b/src/Plugin/QueueWorker/ProcessSourceWorker.php @@ -0,0 +1,79 @@ +fixity = $fixity; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('dgi_fixity.fixity_check'), + ); + } + + /** + * {@inheritdoc} + */ + public function processItem($data) { + /** @var \Drupal\dgi_fixity\FixityCheckServiceInterface $fixity */ + $fixity = \Drupal::service('dgi_fixity.fixity_check'); + $view = $fixity->source($data, 1000); + $view->execute(); + // Only processes those which have not already enabled periodic checks. + foreach ($view->result as $row) { + /** @var \Drupal\dgi_fixity\FixityCheckInterface $check */ + $check = $view->field['periodic']->getEntity($row); + $check->setPeriodic(TRUE); + $check->save(); + } + // Not finished processing. + if (count($view->result) !== 0) { + throw new RequeueException(); + } + } + +} diff --git a/src/Plugin/Validation/Constraint/UniqueFieldEntityReferenceConstraint.php b/src/Plugin/Validation/Constraint/UniqueFieldEntityReferenceConstraint.php new file mode 100644 index 0000000..b2e34e0 --- /dev/null +++ b/src/Plugin/Validation/Constraint/UniqueFieldEntityReferenceConstraint.php @@ -0,0 +1,24 @@ +first()) { + return; + } + $target_property = $item->mainPropertyName(); + $field_name = $items->getFieldDefinition()->getName(); + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $items->getEntity(); + $entity_type_id = $entity->getEntityTypeId(); + $id_key = $entity->getEntityType()->getKey('id'); + + $query = \Drupal::entityQuery($entity_type_id); + $query->accessCheck(FALSE); + + $entity_id = $entity->id(); + // Using isset() instead of !empty() as 0 and '0' are valid ID values for + // entity types using string IDs. + if (isset($entity_id)) { + $query->condition($id_key, $entity_id, '<>'); + } + $targets = []; + foreach ($items as $item) { + $targets[] = $item->{$target_property}; + } + + $targets = array_filter($targets); + $results = $query + ->condition($field_name, $targets, 'IN') + ->range(0, 1) + ->execute(); + + if (count($results) > 0) { + $used_by_entity_id = reset($results); + $used_by_entity = \Drupal::entityTypeManager()->getStorage($entity_type_id)->load($used_by_entity_id); + foreach ($used_by_entity->{$field_name}->referencedEntities() as $reference) { + if (in_array($reference->id(), $targets)) { + $this->context->addViolation($constraint->message, [ + '@referenced_entity_type' => $reference->getEntityTypeId(), + '%entity_referenced' => $reference->id(), + '@entity_type' => $entity_type_id, + '%entity' => $used_by_entity_id, + ]); + break; + } + } + } + } + +} diff --git a/src/Plugin/views/wizard/FixityCheck.php b/src/Plugin/views/wizard/FixityCheck.php new file mode 100644 index 0000000..8e10bf4 --- /dev/null +++ b/src/Plugin/views/wizard/FixityCheck.php @@ -0,0 +1,23 @@ +fixity = $fixity; + } + + /** + * {@inheritdoc} + */ + public function convert($value, $definition, $name, array $defaults) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = parent::convert($value, $definition, $name, $defaults); + return $this->fixity->fromEntity($entity); + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + $supported_entity_types = $this->fixity->fromEntityTypes(); + if (!empty($definition['type']) && strpos($definition['type'], 'fixity:') === 0) { + $entity_type_id = substr($definition['type'], strlen('fixity:')); + if (strpos($definition['type'], '{') !== FALSE) { + $entity_type_slug = substr($entity_type_id, 1, -1); + if ($name != $entity_type_slug && in_array($entity_type_slug, $route->compile()->getVariables(), TRUE)) { + return in_array($entity_type_slug, $supported_entity_types); + } + } + return in_array($entity_type_id, $supported_entity_types); + } + return FALSE; + } + +} diff --git a/src/Routing/FixityCheckRouteProvider.php b/src/Routing/FixityCheckRouteProvider.php new file mode 100644 index 0000000..831aa3d --- /dev/null +++ b/src/Routing/FixityCheckRouteProvider.php @@ -0,0 +1,25 @@ +setOption('_admin_route', TRUE); + return $route; + } + } + +} diff --git a/src/Routing/FixityCheckRouteSubscriber.php b/src/Routing/FixityCheckRouteSubscriber.php new file mode 100644 index 0000000..d6b0c6b --- /dev/null +++ b/src/Routing/FixityCheckRouteSubscriber.php @@ -0,0 +1,113 @@ +entityTypeManager = $entity_manager; + $this->fixity = $fixity; + } + + /** + * {@inheritdoc} + */ + protected function alterRoutes(RouteCollection $collection) { + $supported_entity_types = array_merge(['fixity_check'], $this->fixity->fromEntityTypes()); + $definitions = $this->entityTypeManager->getDefinitions(); + foreach ($supported_entity_types as $entity_type_id) { + $entity_type = $definitions[$entity_type_id]; + if ($route = $this->getFixityAuditRoute($entity_type)) { + $collection->add("entity.$entity_type_id.fixity_audit", $route); + } + if ($route = $this->getFixityCheckRoute($entity_type)) { + $collection->add("entity.$entity_type_id.fixity_check", $route); + } + } + } + + /** + * Gets the fixity check 'Audit' route for the given entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getFixityAuditRoute(EntityTypeInterface $entity_type) { + if ($fixity_audit = $entity_type->getLinkTemplate('fixity-audit')) { + $entity_type_id = $entity_type->id(); + $route = new Route($fixity_audit); + $route + ->addDefaults([ + '_controller' => '\Drupal\dgi_fixity\Controller\FixityCheckController::entityAudit', + '_title' => 'Audit', + ]) + ->addRequirements([ + '_permission' => 'view fixity checks', + ]) + ->setOption('_admin_route', TRUE) + ->setOption('_fixity_entity_type_id', $entity_type_id) + ->setOption('parameters', [ + $entity_type_id => ['type' => 'entity:' . $entity_type_id], + ]); + return $route; + } + } + + /** + * Gets the fixity check 'Check' route for the given entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getFixityCheckRoute(EntityTypeInterface $entity_type) { + if ($fixity_audit = $entity_type->getLinkTemplate('fixity-check')) { + $entity_type_id = $entity_type->id(); + $route = new Route($fixity_audit); + $route + ->addDefaults([ + '_entity_form' => "{$entity_type_id}.fixity-check", + ]) + ->addRequirements([ + '_permission' => 'administer fixity checks', + ]) + ->setOption('_admin_route', TRUE) + ->setOption('_fixity_entity_type_id', $entity_type_id) + ->setOption('parameters', [ + $entity_type_id => ['type' => 'entity:' . $entity_type_id], + ]); + return $route; + } + } + +}