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; + } + } + +}