og-8.x-1.x-dev/og.module
og.module
<?php
/**
* @file
* Enable users to create and manage groups with roles and permissions.
*/
declare(strict_types=1);
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\Entity\ConfigEntityBundleBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityFormInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\og\Entity\OgRole;
use Drupal\og\Og;
use Drupal\og\OgGroupAudienceHelperInterface;
use Drupal\og\OgMembershipInterface;
use Drupal\og\OgMembershipTypeInterface;
use Drupal\og\OgRoleInterface;
use Drupal\system\Entity\Action;
use Drupal\user\EntityOwnerInterface;
use Drupal\user\UserInterface;
/**
* Implements hook_entity_insert().
*
* Subscribe the group manager.
*/
function og_entity_insert(EntityInterface $entity) {
// Invalidate cache tags if new group content is created.
og_invalidate_group_content_cache_tags($entity);
if (!\Drupal::config('og.settings')->get('auto_add_group_owner_membership')) {
// Disabled automatically adding group creators to the group.
return;
}
if (!Og::isGroup($entity->getEntityTypeId(), $entity->bundle())) {
// Not a group entity.
return;
}
if (!$entity instanceof EntityOwnerInterface) {
return;
}
$owner = $entity->getOwner();
if (!$owner instanceof UserInterface) {
return;
}
if ($owner->isAnonymous()) {
// User is anonymous, so we cannot set a membership for them.
return;
}
// Other modules that implement hook_entity_insert() might already have
// created a membership ahead of us.
if (!Og::getMembership($entity, $owner, OgMembershipInterface::ALL_STATES)) {
$membership = Og::createMembership($entity, $owner);
$membership->save();
}
}
/**
* Implements hook_entity_update().
*/
function og_entity_update(EntityInterface $entity) {
// Invalidate cache tags if a group or group content entity is updated.
og_invalidate_group_content_cache_tags($entity);
}
/**
* Implements hook_entity_predelete().
*/
function og_entity_predelete(EntityInterface $entity) {
if (Og::isGroup($entity->getEntityTypeId(), $entity->bundle())) {
// Register orphaned group content and user memberships for deletion, if
// this option has been enabled.
$config = \Drupal::config('og.settings');
if ($config->get('delete_orphans')) {
$plugin_id = $config->get('delete_orphans_plugin_id');
/** @var \Drupal\og\OgDeleteOrphansInterface $plugin */
$plugin = \Drupal::service('plugin.manager.og.delete_orphans')->createInstance($plugin_id, []);
$plugin->register($entity);
}
// @todo Delete user roles.
// @see https://github.com/amitaibu/og/issues/175
// og_delete_user_roles_by_group($entity_type, $entity);
}
// If a user is being deleted, also delete its memberships.
if ($entity instanceof UserInterface) {
/** @var \Drupal\og\MembershipManagerInterface $membership_manager */
$membership_manager = \Drupal::service('og.membership_manager');
foreach ($membership_manager->getMemberships($entity->id(), []) as $membership) {
$membership->delete();
}
}
}
/**
* Implements hook_entity_delete().
*/
function og_entity_delete(EntityInterface $entity) {
// Invalidate cache tags after a group or group content entity is deleted.
og_invalidate_group_content_cache_tags($entity);
// If a group content type is deleted, make sure to remove it from the list of
// groups.
if ($entity instanceof ConfigEntityBundleBase) {
$bundle = $entity->id();
$entity_type_id = \Drupal::entityTypeManager()->getDefinition($entity->getEntityTypeId())->getBundleOf();
if (Og::isGroup($entity_type_id, $bundle)) {
Og::groupTypeManager()->removeGroup($entity_type_id, $bundle);
}
}
}
/**
* Implements hook_entity_presave().
*
* Preserve hidden (unauthorized) group references on edit so users who lack
* access cannot silently drop group memberships by saving a form. Editors can
* still remove groups they are allowed to manage; only inaccessible references
* are restored.
*/
function og_entity_presave(EntityInterface $entity): void {
// Only deal with editing group content when preserving hidden groups.
if ($entity->isNew() || !Og::isGroupContent($entity->getEntityTypeId(), $entity->bundle())) {
return;
}
/** @var \Drupal\og\OgGroupAudienceHelperInterface $group_audience_helper */
$group_audience_helper = \Drupal::service('og.group_audience_helper');
$audience_fields = $group_audience_helper->getAllGroupAudienceFields($entity->getEntityTypeId(), $entity->bundle());
$account = \Drupal::currentUser();
/** @var \Drupal\og\OgAccessInterface $og_access */
$og_access = \Drupal::service('og.access');
foreach (array_keys($audience_fields) as $field_name) {
$field = $entity->get($field_name);
$current_ids = [];
foreach ($field->getValue() as $item) {
if (isset($item['target_id'])) {
$current_ids[] = (int) $item['target_id'];
}
}
$original_values = NULL;
if (isset($entity->original) && $entity->original instanceof FieldableEntityInterface && $entity->original->hasField($field_name)) {
$original_values = $entity->original->get($field_name)->getValue();
}
elseif ($entity->id()) {
$storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
$unchanged = $storage->loadUnchanged($entity->id());
if ($unchanged instanceof FieldableEntityInterface && $unchanged->hasField($field_name)) {
$original_values = $unchanged->get($field_name)->getValue();
}
}
// Nothing to restore when the original field was empty.
if (!$original_values) {
continue;
}
$original_ids = [];
foreach ($original_values as $item) {
if (isset($item['target_id'])) {
$original_ids[] = (int) $item['target_id'];
}
}
// Field had no original references, nothing to preserve.
if (!$original_ids) {
continue;
}
$missing_ids = array_diff($original_ids, $current_ids);
// No hidden references were removed in the submission.
if (!$missing_ids) {
continue;
}
$target_type = $field->getFieldDefinition()->getSetting('target_type');
$group_storage = \Drupal::entityTypeManager()->getStorage($target_type);
$groups = $group_storage->loadMultiple($missing_ids);
$preserve_ids = [];
foreach ($missing_ids as $group_id) {
if (!isset($groups[$group_id])) {
continue;
}
$access = $og_access->userAccessGroupContentEntityOperation('create', $groups[$group_id], $entity, $account);
if (!$access->isAllowed()) {
$preserve_ids[] = (int) $group_id;
}
}
// All removed groups are still allowed; nothing to re-add.
if (!$preserve_ids) {
continue;
}
// Keep original order; preserve missing unauthorized IDs, then append any
// new selections from the form.
$final_ids = [];
foreach ($original_ids as $group_id) {
if (in_array($group_id, $current_ids, TRUE) || in_array($group_id, $preserve_ids, TRUE)) {
$final_ids[] = $group_id;
}
}
foreach ($current_ids as $group_id) {
if (!in_array($group_id, $final_ids, TRUE)) {
$final_ids[] = $group_id;
}
}
$field->setValue(array_map(static fn(int $group_id): array => ['target_id' => $group_id], $final_ids));
}
}
/**
* Implements hook_entity_access().
*/
function og_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
// Grant access to view roles, so that they can be shown in listings.
if ($entity instanceof OgRoleInterface && $operation === 'view') {
return AccessResult::allowed();
}
// We only care about content entities that are groups or group content.
if (!$entity instanceof ContentEntityInterface) {
return AccessResult::neutral();
}
if ($operation === 'view') {
return AccessResult::neutral();
}
$entity_type_id = $entity->getEntityTypeId();
$bundle_id = $entity->bundle();
$is_group_content = Og::isGroupContent($entity_type_id, $bundle_id);
// If the entity is neither a group or group content, then we have no opinion.
if (!Og::isGroup($entity_type_id, $bundle_id) && !$is_group_content) {
return AccessResult::neutral();
}
// If the entity type is a group content type, but the entity is not
// associated with any groups, we have no opinion.
if ($is_group_content && \Drupal::service('og.membership_manager')->getGroupCount($entity) === 0) {
return AccessResult::neutral();
}
// If the user has the global permission to administer all groups, allow
// access.
if ($account->hasPermission('administer organic groups')) {
return AccessResult::allowed();
}
/** @var \Drupal\Core\Access\AccessResult $access */
$access = \Drupal::service('og.access')->userAccessEntityOperation($operation, $entity, $account);
if ($access->isAllowed()) {
return $access;
}
if ($entity_type_id === 'node') {
$node_access_strict = \Drupal::config('og.settings')->get('node_access_strict');
// Otherwise, ignore or deny based on whether strict node access is set.
return AccessResult::forbiddenIf($node_access_strict);
}
return AccessResult::forbidden();
}
/**
* Implements hook_ENTITY_TYPE_access().
*/
function og_og_membership_type_access(OgMembershipTypeInterface $entity, $operation, AccountInterface $account) {
// Do not allow deleting the default membership type.
if ($operation === 'delete' && $entity->id() === OgMembershipInterface::TYPE_DEFAULT) {
return AccessResult::forbidden();
}
// If the user has permission to administer all groups, allow access.
if ($account->hasPermission('administer organic groups')) {
return AccessResult::allowed();
}
return AccessResult::forbidden();
}
/**
* Implements hook_entity_create_access().
*/
function og_entity_create_access(AccountInterface $account, array $context, $bundle) {
$entity_type_id = $context['entity_type_id'];
if (!Og::isGroupContent($entity_type_id, $bundle)) {
// Not a group content.
return AccessResult::neutral();
}
// A user with the global permission to administer all groups has full access.
$access_result = AccessResult::allowedIfHasPermission($account, 'administer organic groups');
if ($access_result->isAllowed()) {
return $access_result;
}
$node_access_strict = \Drupal::config('og.settings')->get('node_access_strict');
if ($entity_type_id === 'node' && !$node_access_strict && $account->hasPermission("create $bundle content")) {
// The user has the core permission and strict node access is not set.
return AccessResult::neutral();
}
// We can't check if user has create permissions, as there is no group
// context. However, we can check if there are any groups the user will be
// able to select, and if not, we don't allow access but if there are,
// AccessResult::neutral() will be returned in order to not override other
// access results.
// @see \Drupal\og\Plugin\EntityReferenceSelection\OgSelection::buildEntityQuery()
$required = FALSE;
$field_definitions = \Drupal::service('entity_field.manager')->getFieldDefinitions($entity_type_id, $bundle);
foreach ($field_definitions as $field_definition) {
/** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
if (!\Drupal::service('og.group_audience_helper')->isGroupAudienceField($field_definition)) {
continue;
}
$options = [
'target_type' => $field_definition->getFieldStorageDefinition()->getSetting('target_type'),
'handler' => $field_definition->getSetting('handler'),
];
/** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManager $handler */
$handler = \Drupal::service('plugin.manager.entity_reference_selection');
if ($handler->getInstance($options)) {
return AccessResult::neutral();
}
// Allow users to create content outside of groups, if none of the
// audience fields is required.
$required = $field_definition->isRequired();
}
// Otherwise, ignore or deny based on whether strict entity access is set.
return $required ? AccessResult::forbiddenIf($node_access_strict) : AccessResult::neutral();
}
/**
* Implements hook_entity_bundle_field_info().
*
* Add a read only property to group entities as a group flag.
*/
function og_entity_bundle_field_info(EntityTypeInterface $entity_type, $bundle, array $base_field_definitions) {
if (!Og::isGroup($entity_type->id(), $bundle)) {
// Not a group type.
return NULL;
}
$fields = [];
$fields['og_group'] = BaseFieldDefinition::create('og_group')
->setLabel(new TranslatableMarkup('OG Group'))
->setComputed(TRUE)
->setTranslatable(FALSE)
->setDefaultValue(TRUE)
->setReadOnly(TRUE);
return $fields;
}
/**
* Implements hook_entity_bundle_field_info_alter().
*
* Set the default field formatter of fields of type OG group.
*/
function og_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
if (!isset($fields['og_group'])) {
// No OG group fields.
return;
}
$fields['og_group']->setDisplayOptions('view', [
'weight' => 0,
'type' => 'og_group_subscribe',
])->setDisplayConfigurable('view', TRUE);
}
/**
* Implements hook_field_formatter_info_alter().
*
* Allow OG audience fields to have entity reference formatters.
*/
function og_field_formatter_info_alter(array &$info) {
foreach (array_keys($info) as $key) {
if (!in_array('entity_reference', $info[$key]['field_types'])) {
// Not an entity reference formatter.
continue;
}
$info[$key]['field_types'][] = OgGroupAudienceHelperInterface::GROUP_REFERENCE;
}
}
/**
* Implements hook_field_widget_info_alter().
*/
function og_field_widget_info_alter(array &$info) {
$field_types = [
'entity_reference_autocomplete',
'entity_reference_autocomplete_tags',
'options_buttons',
'options_select',
];
foreach ($field_types as $field_type) {
$info[$field_type]['field_types'][] = OgGroupAudienceHelperInterface::GROUP_REFERENCE;
}
}
/**
* Implements hook_element_info_alter().
*
* Inject our process callback into core's 'entity_autocomplete' element so we
* can attach the group-content context (entity type + bundle) to the AJAX
* request. The OG selection handler reads these query parameters to scope
* results and access checks to the correct group, even when the full form
* entity isn't available during the autocomplete request lifecycle.
*
* Specifically, 'entity_type' and 'bundle' are consumed in
* OgSelection::buildEntityQuery() to construct a stub entity and apply
* group-aware filtering.
*
* @see \Drupal\og\Plugin\EntityReferenceSelection\OgSelection::buildEntityQuery()
*/
function og_element_info_alter(array &$type) {
if (isset($type['entity_autocomplete'])) {
array_unshift($type['entity_autocomplete']['#process'], 'og_process_entity_autocomplete');
}
}
/**
* Custom process callback for 'entity_autocomplete' on OG audience fields.
*
* When the element belongs to a group-content entity form, add 'entity_type'
* and 'bundle' to '#autocomplete_query_parameters'. The OG selection handler
* consumes these to create a stub entity and perform group-aware access checks
* and filtering, ensuring autocomplete suggestions respect the group context.
*
* @see \Drupal\og\Plugin\EntityReferenceSelection\OgSelection::buildEntityQuery()
*/
function og_process_entity_autocomplete(array &$element, FormStateInterface $form_state, array &$complete_form) {
if (!($form_state->getFormObject() instanceof EntityFormInterface)) {
return $element;
}
$entity = $form_state->getFormObject()->getEntity();
if (!($entity instanceof ContentEntityInterface)) {
return $element;
}
// Guarding against bad parents.
if (empty($element['#parents'])) {
return $element;
}
// Walk up the parents to find the actual entity field name.
$field_definition = NULL;
foreach ($element['#parents'] as $parent) {
if ($entity->hasField($parent)) {
$field_definition = $entity->getFieldDefinition($parent);
break;
}
}
if (!$field_definition instanceof FieldDefinitionInterface) {
return $element;
}
if (\Drupal::service('og.group_audience_helper')->isGroupAudienceField($field_definition)) {
// Pass minimal context so the selection handler can scope the query.
// These are read by OgSelection::buildEntityQuery() when the full entity
// isn't available to the autocomplete callback.
// @see \Drupal\og\Plugin\EntityReferenceSelection\OgSelection::buildEntityQuery()
$element['#autocomplete_query_parameters']['entity_type'] = $form_state->getFormObject()->getEntity()->getEntityTypeId();
$element['#autocomplete_query_parameters']['bundle'] = $form_state->getFormObject()->getEntity()->bundle();
}
return $element;
}
/**
* Implements hook_entity_type_alter().
*
* Add link template to groups. We add it to all the entity types, and later on
* return the correct access, depending if the bundle is indeed a group and
* accessible. We do not filter here the entity type by groups, so whenever
* GroupTypeManagerInterface::addGroup is called, it's enough to mark route to
* be rebuilt via RouteBuilder::setRebuildNeeded.
*/
function og_entity_type_alter(array &$entity_types) {
/** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
foreach ($entity_types as $entity_type_id => $entity_type) {
// Register 'og-admin-routes' link template; access is checked at runtime.
$entity_type->setLinkTemplate('og-admin-routes', "/group/$entity_type_id/{{$entity_type_id}}/admin");
}
}
/**
* Implements hook_theme().
*/
function og_theme($existing, $type, $theme, $path) {
return [
'og_member_count' => [
'variables' => [
'count' => 0,
'membership_states' => [],
'group' => NULL,
'group_label' => NULL,
],
],
];
}
/**
* Invalidates group content cache tags for the groups this entity belongs to.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The group content entity that is being created, changed or deleted and is
* the direct cause for the need to invalidate cached group content.
*/
function og_invalidate_group_content_cache_tags(EntityInterface $entity) {
// If group content is created or updated, invalidate the group content cache
// tags for each of the groups this group content belongs to. This allows
// group listings to be cached effectively. The cache tag format is
// 'og-group-content:{group entity type}:{group entity id}'.
$is_group_content = Og::isGroupContent($entity->getEntityTypeId(), $entity->bundle());
if ($is_group_content) {
/** @var \Drupal\og\MembershipManagerInterface $membership_manager */
$membership_manager = \Drupal::service('og.membership_manager');
$tags = [];
// If the entity is a group content and we came here as an effect of an
// update, check if any of the OG audience fields have been changed. This
// means the group(s) of the entity changed and we should also invalidate
// the tags of the old group(s).
/** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
$original = !empty($entity->original) ? $entity->original : NULL;
if ($original) {
/** @var \Drupal\og\OgGroupAudienceHelperInterface $group_audience_helper */
$group_audience_helper = \Drupal::service('og.group_audience_helper');
/** @var \Drupal\Core\Entity\FieldableEntityInterface $original */
foreach ($group_audience_helper->getAllGroupAudienceFields($entity->getEntityTypeId(), $entity->bundle()) as $field) {
$field_name = $field->getName();
/** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $original_field_item_list */
$original_field_item_list = $original->get($field_name);
if (!$entity->get($field_name)->equals($original_field_item_list)) {
foreach ($original_field_item_list->referencedEntities() as $old_group) {
$tags = Cache::mergeTags($tags, $old_group->getCacheTagsToInvalidate());
}
}
}
}
foreach ($membership_manager->getGroups($entity) as $groups) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $group */
foreach ($groups as $group) {
$tags = Cache::mergeTags($tags, $group->getCacheTagsToInvalidate());
}
}
Cache::invalidateTags(Cache::buildTags('og-group-content', $tags));
}
}
/**
* Implements hook_ENTITY_TYPE_insert() for OgRole entities.
*/
function og_og_role_insert(OgRoleInterface $role) {
// Create actions to add or remove the role, except for the required default
// roles 'member' and 'non-member'. These cannot be added or removed.
if ($role->getRoleType() === OgRoleInterface::ROLE_TYPE_REQUIRED) {
return;
}
// Skip creation of action plugins while config import is in progress.
if ($role->isSyncing()) {
return;
}
$add_id = 'og_membership_add_single_role_action.' . $role->getName();
if (!Action::load($add_id)) {
$action = Action::create([
'id' => $add_id,
'type' => 'og_membership',
'label' => new TranslatableMarkup('Add the @label role to the selected members', ['@label' => $role->getName()]),
'configuration' => [
'role_name' => $role->getName(),
],
'plugin' => 'og_membership_add_single_role_action',
]);
$action->trustData()->save();
}
$remove_id = 'og_membership_remove_single_role_action.' . $role->getName();
if (!Action::load($remove_id)) {
$action = Action::create([
'id' => $remove_id,
'type' => 'og_membership',
'label' => new TranslatableMarkup('Remove the @label role from the selected members', ['@label' => $role->getName()]),
'configuration' => [
'role_name' => $role->getName(),
],
'plugin' => 'og_membership_remove_single_role_action',
]);
$action->trustData()->save();
}
}
/**
* Implements hook_ENTITY_TYPE_delete() for OgRole entities.
*/
function og_og_role_delete(OgRoleInterface $role) {
$role_name = $role->getName();
/** @var \Drupal\system\ActionConfigEntityInterface[] $actions */
$actions = Action::loadMultiple([
'og_membership_add_single_role_action.' . $role_name,
'og_membership_remove_single_role_action.' . $role_name,
]);
// Only remove the actions when the role name is not used by any other roles.
foreach (OgRole::loadMultiple() as $role) {
if ($role->getName() === $role_name) {
return;
}
}
foreach ($actions as $action) {
$action->delete();
}
}
