diff --git a/app/code/core/Mage/Checkout/controllers/CartController.php b/app/code/core/Mage/Checkout/controllers/CartController.php index 49854d85a3..d9626e666c 100644 --- a/app/code/core/Mage/Checkout/controllers/CartController.php +++ b/app/code/core/Mage/Checkout/controllers/CartController.php @@ -855,7 +855,7 @@ protected function _tryApplyGiftcard(string $code): array // Check website validity $websiteId = (int) $quote->getStore()->getWebsiteId(); - if ((int) $giftcard->getWebsiteId() !== $websiteId) { + if (!in_array($websiteId, $giftcard->getWebsiteIds(), true)) { return ['success' => false, 'message' => '']; } diff --git a/app/code/core/Maho/ApiPlatform/symfony/Trait/StoreRestrictionTrait.php b/app/code/core/Maho/ApiPlatform/symfony/Trait/StoreRestrictionTrait.php index 805aced992..cb88d6c98a 100644 --- a/app/code/core/Maho/ApiPlatform/symfony/Trait/StoreRestrictionTrait.php +++ b/app/code/core/Maho/ApiPlatform/symfony/Trait/StoreRestrictionTrait.php @@ -84,6 +84,45 @@ protected function assertWebsiteAllowed(int|string|null $websiteId, ApiUser $use } } + /** + * Read gate for an entity associated with a set of websites: deny when none + * of them is within the user's scope. An empty set is denied, matching the + * single-website rule. + * + * @param int[] $entityWebsiteIds + */ + protected function assertAnyWebsiteAllowed(array $entityWebsiteIds, ApiUser $user, string $entityLabel): void + { + $websiteIds = $this->allowedWebsiteIds($user); + if ($websiteIds === null) { + return; + } + + if (array_intersect(array_map(intval(...), $entityWebsiteIds), $websiteIds) === []) { + throw new AccessDeniedHttpException("Access denied for this {$entityLabel}'s website"); + } + } + + /** + * Write gate for an entity associated with a set of websites: deny unless + * every one of them is within the user's scope, so a restricted token + * cannot edit a record that also reaches a website it cannot see. + * + * @param int[] $entityWebsiteIds + */ + protected function assertAllWebsitesAllowed(array $entityWebsiteIds, ApiUser $user, string $entityLabel): void + { + $websiteIds = $this->allowedWebsiteIds($user); + if ($websiteIds === null) { + return; + } + + $entityWebsiteIds = array_map(intval(...), $entityWebsiteIds); + if ($entityWebsiteIds === [] || array_diff($entityWebsiteIds, $websiteIds) !== []) { + throw new AccessDeniedHttpException("Access denied for this {$entityLabel}'s website"); + } + } + /** * Map a restricted user's allowed store ids to their website ids; null for * unrestricted users, meaning "no website restriction". An allowlisted diff --git a/app/code/core/Maho/Giftcard/Api/GiftCard.php b/app/code/core/Maho/Giftcard/Api/GiftCard.php index edc9362350..214bd8f4ec 100644 --- a/app/code/core/Maho/Giftcard/Api/GiftCard.php +++ b/app/code/core/Maho/Giftcard/Api/GiftCard.php @@ -75,7 +75,7 @@ 'senderName' => ['type' => 'String', 'description' => 'Sender name'], 'senderEmail' => ['type' => 'String', 'description' => 'Sender email'], 'message' => ['type' => 'String', 'description' => 'Gift card message'], - 'websiteId' => ['type' => 'Int', 'description' => 'Website ID (defaults to current)'], + 'websiteIds' => ['type' => '[Int!]', 'description' => 'Website IDs the card is valid on (defaults to current website)'], 'expiresAt' => ['type' => 'String', 'description' => 'Expiration date (YYYY-MM-DD)'], ], description: 'Create a new gift card', @@ -115,7 +115,6 @@ class GiftCard extends CrudResource public ?string $status = null; public ?string $expiresAt = null; - public ?int $websiteId = null; #[ApiProperty(writable: false)] public ?string $currencyCode = null; @@ -144,6 +143,15 @@ class GiftCard extends CrudResource public ?string $senderEmail = null; public ?string $message = null; + /** + * Websites the card is valid on (giftcard_website junction). Maps to the + * model's `website_ids` pending-change key, so applyToModel() feeds the + * junction sync; omitting it on create falls back to the current website. + * + * @var int[]|null + */ + public ?array $websiteIds = null; + /** Not persisted on the card: recorded as the giftcard_history comment of a balance adjustment. */ #[ApiProperty(readable: false)] public ?string $comment = null; @@ -161,6 +169,12 @@ public static function afterLoad(self $dto, object $model): void { $dto->currencyCode = $model->getCurrencyCode(); + // The junction is lazy-loaded, never present in the model's data + // array, so fromModel()'s convention mapping leaves this null. + if ($model instanceof \Maho_Giftcard_Model_Giftcard && $model->getId()) { + $dto->websiteIds = $model->getWebsiteIds(); + } + foreach ($model->getHistoryCollection() as $entry) { $dto->history[] = [ 'action' => $entry->getData('action'), diff --git a/app/code/core/Maho/Giftcard/Api/GiftCardProcessor.php b/app/code/core/Maho/Giftcard/Api/GiftCardProcessor.php index 2607180a01..a5431fd8c3 100644 --- a/app/code/core/Maho/Giftcard/Api/GiftCardProcessor.php +++ b/app/code/core/Maho/Giftcard/Api/GiftCardProcessor.php @@ -46,22 +46,26 @@ protected function afterSave(object $model, CrudResource $data): void } /** - * REST create path: default an omitted websiteId to the current store's - * website (a NULL-website card is unredeemable everywhere), then enforce - * the token's website scope on the resolved value. + * REST create path: default omitted websiteIds to the current store's + * website (a card with no website is unredeemable everywhere), then enforce + * the token's website scope on the resolved set. */ #[\Override] protected function beforeSave(object $model, CrudResource $data, ApiUser $user): void { - if (!$model->getId() && !$model->getData('website_id')) { - $model->setData('website_id', (int) StoreContext::getStore()->getWebsiteId()); + if (!$model->getId() && $model->getData('website_ids') === null) { + $model->setWebsiteIds([(int) StoreContext::getStore()->getWebsiteId()]); } if ($model->getId()) { // The card as stored must be in scope too, or a restricted token - // could claim a foreign card by rewriting its websiteId. - $this->assertWebsiteAllowed($model->getOrigData('website_id'), $user, 'gift card'); + // could claim a foreign card by rewriting its websiteIds. + $this->assertAllWebsitesAllowed( + $model->getResource()->getWebsiteIds((int) $model->getId()), + $user, + 'gift card', + ); } - $this->assertWebsiteAllowed((int) $model->getData('website_id'), $user, 'gift card'); + $this->assertAllWebsitesAllowed($model->getWebsiteIds(), $user, 'gift card'); } /** Balance bounds and duplicate-code check for the REST CRUD create/update path. */ @@ -85,12 +89,17 @@ protected function validate(CrudResource $data, object $model, bool $isNew): voi $this->assertBalanceBounds((float) $data->balance); } $this->assertValidStatus($data->status); - if ($data->websiteId !== null) { - try { - \Mage::app()->getWebsite($data->websiteId); - } catch (\Throwable) { - throw new BadRequestHttpException("Unknown website id {$data->websiteId}"); - } + foreach ($data->websiteIds ?? [] as $websiteId) { + $this->assertKnownWebsite((int) $websiteId); + } + } + + private function assertKnownWebsite(int $websiteId): void + { + try { + \Mage::app()->getWebsite($websiteId); + } catch (\Throwable) { + throw new BadRequestHttpException("Unknown website id {$websiteId}"); } } @@ -118,7 +127,7 @@ protected function processUpdate(int $id, mixed $data, ApiUser $user): mixed /** @var \Maho_Giftcard_Model_Giftcard $model */ $model = $this->loadOrFail($this->modelAlias, $id, 'Gift card not found'); - $this->assertWebsiteAllowed($model->getWebsiteId(), $user, 'gift card'); + $this->assertAllWebsitesAllowed($model->getWebsiteIds(), $user, 'gift card'); $oldData = $model->getData(); $this->assertValidStatus($data->status); @@ -186,18 +195,16 @@ private function createGiftcardFromGraphQl(array $context): GiftCard null, ); - // Default an omitted websiteId to the current store's website (the - // documented behavior; a NULL-website card is unredeemable everywhere) - // and enforce the token's website scope on the resolved value. - $websiteId = isset($args['websiteId']) - ? (int) $args['websiteId'] - : (int) StoreContext::getStore()->getWebsiteId(); - try { - \Mage::app()->getWebsite($websiteId); - } catch (\Throwable) { - throw new BadRequestHttpException("Unknown website id {$websiteId}"); + // Default omitted websiteIds to the current store's website (the + // documented behavior; a card with no website is unredeemable + // everywhere) and enforce the token's website scope on the resolved set. + $websiteIds = empty($args['websiteIds']) + ? [(int) StoreContext::getStore()->getWebsiteId()] + : array_map(intval(...), (array) $args['websiteIds']); + foreach ($websiteIds as $websiteId) { + $this->assertKnownWebsite($websiteId); } - $this->assertWebsiteAllowed($websiteId, $this->requireUser(), 'gift card'); + $this->assertAllWebsitesAllowed($websiteIds, $this->requireUser(), 'gift card'); $giftcard = \Mage::getModel('giftcard/giftcard'); $giftcard->setData([ @@ -209,9 +216,9 @@ private function createGiftcardFromGraphQl(array $context): GiftCard 'sender_name' => $args['senderName'] ?? null, 'sender_email' => $args['senderEmail'] ?? null, 'message' => $args['message'] ?? null, - 'website_id' => $websiteId, 'expires_at' => $args['expiresAt'] ?? null, ]); + $giftcard->setWebsiteIds($websiteIds); $giftcard->save(); $this->sendEmailToRecipient($giftcard); @@ -251,7 +258,7 @@ private function adjustBalance(array $context): GiftCard throw new NotFoundHttpException('Gift card not found'); } - $this->assertWebsiteAllowed($giftcard->getWebsiteId(), $this->requireUser(), 'gift card'); + $this->assertAllWebsitesAllowed($giftcard->getWebsiteIds(), $this->requireUser(), 'gift card'); $this->assertBalanceBounds($newBalance); $giftcard->adjustBalance($newBalance, $args['comment'] ?? null); diff --git a/app/code/core/Maho/Giftcard/Api/GiftCardProvider.php b/app/code/core/Maho/Giftcard/Api/GiftCardProvider.php index ea46876897..8ae6ceb383 100644 --- a/app/code/core/Maho/Giftcard/Api/GiftCardProvider.php +++ b/app/code/core/Maho/Giftcard/Api/GiftCardProvider.php @@ -36,10 +36,10 @@ protected function handleOperation(string $name, array $context, array $uriVaria } $giftcard = \Mage::getModel('giftcard/giftcard')->loadByCode(trim($code)); - // A card is only redeemable on its own website, so another - // website's store must not answer for it either. + // A card is only redeemable on the websites it is associated with, + // so any other website's store must not answer for it either. if (!$giftcard->getId() - || (int) $giftcard->getWebsiteId() !== (int) StoreContext::getStore()->getWebsiteId() + || !in_array((int) StoreContext::getStore()->getWebsiteId(), $giftcard->getWebsiteIds(), true) ) { throw new \RuntimeException('Gift card not found'); } @@ -51,28 +51,32 @@ protected function handleOperation(string $name, array $context, array $uriVaria } /** - * Admin/service item read, restricted tokens only see cards on their - * allowed websites. + * Admin/service item read, restricted tokens only see cards reaching at + * least one of their allowed websites. */ #[\Override] protected function provideItem(int|string $id): ?Resource { $dto = parent::provideItem($id); if ($dto instanceof GiftCard) { - $this->assertWebsiteAllowed($dto->websiteId, $this->requireUser(), 'gift card'); + $this->assertAnyWebsiteAllowed($dto->websiteIds ?? [], $this->requireUser(), 'gift card'); } return $dto; } /** * Admin/service list, restricted tokens only see cards on their allowed - * websites. + * websites. Membership lives in the giftcard_website junction, so this + * cannot use the shared main_table.website_id filter. */ #[\Override] protected function applyCollectionFilters(object $collection, array $filters): void { parent::applyCollectionFilters($collection, $filters); - $this->applyAllowedWebsiteFilter($collection, $this->requireUser()); + $allowedWebsiteIds = $this->allowedWebsiteIds($this->requireUser()); + if ($allowedWebsiteIds !== null) { + $collection->addWebsiteIdsFilter($allowedWebsiteIds); + } } /** diff --git a/app/code/core/Maho/Giftcard/Block/Adminhtml/Giftcard/Edit/Form.php b/app/code/core/Maho/Giftcard/Block/Adminhtml/Giftcard/Edit/Form.php index 1eae09492d..6caecf655e 100644 --- a/app/code/core/Maho/Giftcard/Block/Adminhtml/Giftcard/Edit/Form.php +++ b/app/code/core/Maho/Giftcard/Block/Adminhtml/Giftcard/Edit/Form.php @@ -8,266 +8,28 @@ declare(strict_types=1); +/** + * Empty `