diff --git a/README.md b/README.md index d737a86f..d7d998fb 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ class Page extends SiteTree ]; private static $has_many = [ - 'HasManyLinks' => Link::class + 'HasManyLinks' => Link::class . '.Owner', ]; public function getCMSFields() @@ -63,8 +63,6 @@ class Page extends SiteTree } ``` -Note that you also need to add a `has_one` relation on the `Link` model to match your `has_many` here. See [official docs about `has_many`](https://docs.silverstripe.org/en/developer_guides/model/relations/#has-many) - ## Migrating from Shae Dawson's Linkable module https://github.com/sheadawson/silverstripe-linkable diff --git a/src/Models/FileLink.php b/src/Models/FileLink.php index 15c7779e..f604c477 100644 --- a/src/Models/FileLink.php +++ b/src/Models/FileLink.php @@ -5,6 +5,12 @@ use SilverStripe\Assets\File; use SilverStripe\Forms\FieldList; +/** + * A link to a File in the CMS + * + * @property int $FileID + * @method File File() + */ class FileLink extends Link { private static string $table_name = 'LinkField_FileLink'; diff --git a/src/Models/Link.php b/src/Models/Link.php index 78e9cb55..635f8618 100644 --- a/src/Models/Link.php +++ b/src/Models/Link.php @@ -12,11 +12,15 @@ use SilverStripe\Forms\RequiredFields; use SilverStripe\LinkField\Type\Registry; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; use SilverStripe\ORM\FieldType\DBHTMLText; /** - * A Link Data Object. This class should be a subclass, and you should never directly interact with a plain Link - * instance + * A Link Data Object. This class should be treated as abstract. You should never directly interact with a plain Link + * instance. + * + * Note that links should be added via a has_one or has_many relation, NEVER a many_many relation. This is because + * some functionality such as the can* methods rely on having a single Owner. * * @property string $Title * @property bool $OpenInNew @@ -30,6 +34,17 @@ class Link extends DataObject 'OpenInNew' => 'Boolean', ]; + private static array $has_one = [ + // Note that this handles one-to-many relations AND one-to-one relations. + // Any has_one pointing at Link will be intentionally double handled - this allows us to use the owner + // for permission checks and to link back to the owner from reports, etc. + // See also the Owner method. + 'Owner' => [ + 'class' => DataObject::class, + DataObjectSchema::HASONE_IS_MULTIRECIPROCAL => true, + ], + ]; + /** * In-memory only property used to change link type * This case is relevant for CMS edit form which doesn't use React driven UI @@ -270,6 +285,78 @@ public function getURL(): string return ''; } + /** + * Get the owner of this link, if there is one. + * + * Returns null if the reciprocal relation is a has_one which no longer contains this link. + */ + public function Owner(): ?DataObject + { + $owner = $this->getComponent('Owner'); + // Since the has_one is being stored in two places, double check the owner + // actually still owns this record. If not, return null. + $ownerRelationType = $owner->getRelationType($this->OwnerRelation); + if ($ownerRelationType === 'has_one') { + $idField = "{$this->OwnerRelation}ID"; + if ($owner->$idField !== $this->ID) { + return null; + } + } + return $owner; + } + + + public function canView($member = null) + { + return $this->canPerformAction(__FUNCTION__, $member); + } + + public function canEdit($member = null) + { + return $this->canPerformAction(__FUNCTION__, $member); + } + + public function canDelete($member = null, $context = []) + { + return $this->canPerformAction(__FUNCTION__, $member); + } + + public function canCreate($member = null, $context = []) + { + return $this->canPerformAction(__FUNCTION__, $member); + } + + public function can($perm, $member = null, $context = []) + { + $check = ucfirst(strtolower($perm)); + return match ($check) { + 'View', 'Create', 'Edit', 'Delete' => $this->{"can$check"}($member, $context), + default => parent::can($perm, $member, $context) + }; + } + + private function canPerformAction(string $canMethod, $member, $context = []) + { + // Allow extensions to override permission checks + $results = $this->extendedCan($canMethod, $member, $context); + if (isset($results)) { + return $results; + } + + // If we have an owner, rely on it to tell us what we can and can't do + $owner = $this->Owner(); + if ($owner && $owner->exists()) { + // Can delete or create links if you can edit its owner. + if ($canMethod === 'canCreate' || $canMethod === 'canDelete') { + $canMethod = 'canEdit'; + } + return $owner->$canMethod($member, $context); + } + + // Default to DataObject's permission checks + return parent::$canMethod($member, $context); + } + /** * Get all link types except the generic one *