Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/components/minishop3/config/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@
} else {
return Response::error($response['message'] ?? 'Token generation failed', $response['code'] ?? 500);
}
});
}, [$tokenMiddleware]);

$router->post('/token/refresh', function () use ($customerAuth) {
return $customerAuth()->refreshToken();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
$modx->log(modX::LOG_LEVEL_ERROR, "[msGallery] Resource {$product->id} is not msProduct");
return '';
}
if ($_ms3LoadedById && !(new CatalogResourceGroupVisibility($modx))->isVisible((int) $product->id)) {
if ($_ms3LoadedById && !(new CatalogResourceGroupVisibility($modx))->isVisibleForRequest((int) $product->id)) {
return '';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
'id' => $product->id
]);
}
if ($_ms3LoadedById && !(new CatalogResourceGroupVisibility($modx))->isVisible((int) $product->id)) {
if ($_ms3LoadedById && !(new CatalogResourceGroupVisibility($modx))->isVisibleForRequest((int) $product->id)) {
return '';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
'id' => $product->id
]);
}
if ($_ms3LoadedById && !(new CatalogResourceGroupVisibility($modx))->isVisible((int) $product->id)) {
if ($_ms3LoadedById && !(new CatalogResourceGroupVisibility($modx))->isVisibleForRequest((int) $product->id)) {
return '';
}

Expand Down
64 changes: 56 additions & 8 deletions core/components/minishop3/elements/snippets/ms3_products.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,63 @@
$_ms3SortBy = $scriptProperties['sortby'] ?? '';
if (!is_array($_ms3SortBy)) {
$_ms3SortBy = (string) $_ms3SortBy;
// Qualify bare resource columns before menuindex CASE injects commas (#741 / #742 review).
// Whitelist + qualify bare columns before menuindex / sortbyOptions inject expressions (#755).
$_ms3Passthrough = [];
if (!empty($scriptProperties['includeTVs'])) {
$tvList = is_array($scriptProperties['includeTVs'])
? $scriptProperties['includeTVs']
: array_map('trim', explode(',', (string) $scriptProperties['includeTVs']));
foreach ($tvList as $tvName) {
if (is_string($tvName) && $tvName !== '') {
$_ms3Passthrough[] = $tvName;
}
}
}
if (!empty($scriptProperties['includeVendor']) || !empty($scriptProperties['includeVendorFields'])) {
$vendorFields = !empty($scriptProperties['includeVendorFields'])
? (is_array($scriptProperties['includeVendorFields'])
? $scriptProperties['includeVendorFields']
: array_map('trim', explode(',', (string) $scriptProperties['includeVendorFields'])))
: ['name'];
foreach ($vendorFields as $vendorField) {
if (!is_string($vendorField) || $vendorField === '') {
continue;
}
$_ms3Passthrough[] = str_starts_with($vendorField, 'vendor_')
? $vendorField
: 'vendor_' . $vendorField;
}
}
if (!empty($scriptProperties['sortbyOptions'])) {
foreach (array_map('trim', explode(',', (string) $scriptProperties['sortbyOptions'])) as $sortOpt) {
$optKey = explode(':', $sortOpt)[0] ?? '';
if ($optKey !== '') {
$_ms3Passthrough[] = $optKey;
}
}
}
$_ms3DroppedSortParts = [];
$_ms3SortBy = CatalogSortbyQualifier::qualifyUnaliasedResourceFields(
$_ms3SortBy,
array_keys($modx->getFields(msProduct::class) ?: [])
array_keys($modx->getFields(msProduct::class) ?: []),
'msProduct',
true,
array_keys($modx->getFields(msProductData::class) ?: []),
'Data',
$_ms3Passthrough,
array_values(array_unique(array_merge(
['msProduct', 'Data', 'Vendor'],
CatalogSortbyQualifier::tableAliasesFromJoins($leftJoin, $innerJoin),
))),
$_ms3DroppedSortParts,
);
if ($_ms3DroppedSortParts !== []) {
$modx->log(
\MODX\Revolution\modX::LOG_LEVEL_WARN,
'[MiniShop3] ms3_products dropped unsafe/unknown sortby part(s): '
. implode(' | ', $_ms3DroppedSortParts)
);
}
$scriptProperties['sortby'] = $_ms3SortBy;
}
if ($_ms3MenuindexCategoryIds !== [] && CategoryProductMenuindexService::sortbyRefersToMenuindex($_ms3SortBy)) {
Expand Down Expand Up @@ -270,18 +322,14 @@
}
}

// Anonymous RG ACL for storefront listing (#670); same SQL as Web API.
// Member-aware RG ACL for storefront listing (#670 / #755); same resolver path as Web API.
// Put the NOT EXISTS in an INNER JOIN ON — not in $where[] — so pdoTools
// additionalConditions() does not false-positive-suppress &resources / &context
// (raw numeric where strings that mention msProduct + \bid\b / context_key).
// Self-join on site_content duplicates resource columns: bare multi-column sortby
// is qualified via CatalogSortbyQualifier before pdoTools (#741 / #742 review).
$_ms3RgVisibility = new CatalogResourceGroupVisibility($modx);
$_ms3RgContext = trim((string) ($modx->context->key ?? ''));
if ($_ms3RgContext === '') {
$_ms3RgContext = 'web';
}
$_ms3RgWhere = $_ms3RgVisibility->buildWhereFragment('msProduct', $_ms3RgContext);
$_ms3RgWhere = $_ms3RgVisibility->buildWhereFragmentForRequest('msProduct');
if ($_ms3RgWhere !== null) {
$innerJoin['ms3RgVisibility'] = [
'class' => msProduct::class,
Expand Down
2 changes: 1 addition & 1 deletion core/components/minishop3/lexicon/en/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@
$_lang['setting_ms3_api_debug'] = 'API debug mode';
$_lang['setting_ms3_api_debug_desc'] = 'Enables extended logging of API requests and responses for debugging. Not recommended in production.';
$_lang['setting_ms3_web_catalog_respect_resource_groups'] = 'Respect resource group ACL in public catalog';
$_lang['setting_ms3_web_catalog_respect_resource_groups_desc'] = 'When enabled, the public Web API catalog and Fenom storefront snippets (e.g. ms3_products) hide products and categories that belong to a MODX resource group with Resource Group Access ACL for the request context (anonymous MVP). MiniShop3 invalidates MODX resource (page) cache and facet cache when resource-group ACL or membership changes via the manager plugin. Residual gaps: ACL rows written outside MODX processors (SQL, custom scripts) and HTML cached by an external CDN may stay stale until you clear cache manually. Disable to restore pre-#659 catalog behavior.';
$_lang['setting_ms3_web_catalog_respect_resource_groups_desc'] = 'When enabled, the public Web API catalog and Fenom storefront snippets (e.g. ms3_products) hide products and categories that belong to a MODX resource group with Resource Group Access ACL for the request context. Logged-in customers with a customer group linked to a MODX user group see the same member catalog as Web API. For those member requests MiniShop3 sets the current resource cacheable=0 so member HTML is not stored under the shared page-cache key. On a cacheable MODX page the cached HTML is served before snippets run: after the first anonymous hit a logged-in customer still gets the anonymous list. Therefore member catalog on cacheable pages requires uncached calls [[!ms3_products]] / [[!ms3_gallery]] (without “!” the storefront member ACL never applies). MiniShop3 also invalidates MODX resource (page) cache and facet cache when resource-group ACL, membership, or customer group mapping changes. Residual gaps: ACL rows written outside MODX processors (SQL, custom scripts) and HTML cached by an external CDN may stay stale until you clear cache manually. Disable to restore pre-#659 catalog behavior.';
$_lang['setting_ms3_cors_allowed_origins'] = 'Allowed CORS origins';
$_lang['setting_ms3_cors_allowed_origins_desc'] = 'Comma-separated origins allowed to call the Web API (e.g. https://shop.example.com). Empty = no cross-origin CORS (same-origin only). Use "*" for any origin without credentials; for headless with cookies list explicit domains.';
$_lang['setting_ms3_rate_limit_max_attempts'] = 'API rate limit';
Expand Down
2 changes: 1 addition & 1 deletion core/components/minishop3/lexicon/ru/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@
$_lang['setting_ms3_api_debug'] = 'Режим отладки API';
$_lang['setting_ms3_api_debug_desc'] = 'Включает расширенное логирование API запросов и ответов для отладки. Не рекомендуется на продакшене.';
$_lang['setting_ms3_web_catalog_respect_resource_groups'] = 'Учитывать ACL групп ресурсов в публичном каталоге';
$_lang['setting_ms3_web_catalog_respect_resource_groups_desc'] = 'Если включено, публичный Web API каталог и Fenom-сниппеты витрины (например ms3_products) скрывают товары и категории, входящие в группу ресурсов MODX с ACL «Доступ к группе ресурсов» для контекста запроса (анонимный MVP). MiniShop3 сбрасывает кэш страниц MODX (resource) и кэш фасетов при изменении ACL или членства в группах ресурсов через плагин менеджера. Остаточные пробелы: ACL, записанный в обход процессоров MODX (SQL, свои скрипты), и HTML за внешним CDN могут оставаться устаревшими до ручной очистки кэша. Отключите, чтобы вернуть поведение каталога до #659.';
$_lang['setting_ms3_web_catalog_respect_resource_groups_desc'] = 'Если включено, публичный Web API каталог и Fenom-сниппеты витрины (например ms3_products) скрывают товары и категории, входящие в группу ресурсов MODX с ACL «Доступ к группе ресурсов» для контекста запроса. Залогиненный покупатель с группой клиентов, привязанной к группе пользователей MODX, видит тот же member-каталог, что и Web API. Для таких запросов MiniShop3 ставит текущему ресурсу cacheable=0, чтобы HTML с расширенным каталогом не попал в общий ключ кэша страницы. На кэшируемой странице MODX отдаёт HTML до сниппетов: после первого анонимного визита покупатель снова получит анонимный список. Поэтому для member-каталога на кэшируемых страницах обязательны некэшируемые вызовы [[!ms3_products]] / [[!ms3_gallery]] (без «!» member ACL на витрине не работает). Также сбрасывается кэш страниц MODX (resource) и кэш фасетов при изменении ACL, членства в группах ресурсов или привязки группы клиентов. Остаточные пробелы: ACL, записанный в обход процессоров MODX (SQL, свои скрипты), и HTML за внешним CDN могут оставаться устаревшими до ручной очистки кэша. Отключите, чтобы вернуть поведение каталога до #659.';
$_lang['setting_ms3_cors_allowed_origins'] = 'Разрешённые CORS origins';
$_lang['setting_ms3_cors_allowed_origins_desc'] = 'Origins через запятую для Web API (например https://shop.example.com). Пусто = CORS только same-origin. «*» — любой origin без credentials; для headless с cookies укажите домены явно.';
$_lang['setting_ms3_rate_limit_max_attempts'] = 'Лимит запросов API';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use MiniShop3\Model\msCustomer;
use MiniShop3\Model\msCustomerGroup;
use MiniShop3\Services\Catalog\CatalogAclCacheInvalidator;
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MODX\Revolution\modUserGroup;
Expand Down Expand Up @@ -170,6 +171,9 @@ public function update(array $data = []): array
)->getData();
}

$previousUserGroupId = (int) $group->get('user_group_id');
$previousActive = (bool) $group->get('active');

if (array_key_exists('name', $data)) {
$name = trim((string) $data['name']);
if ($name === '') {
Expand Down Expand Up @@ -203,6 +207,13 @@ public function update(array $data = []): array
)->getData();
}

$aclFieldsChanged = (array_key_exists('user_group_id', $data)
&& (int) $group->get('user_group_id') !== $previousUserGroupId)
|| (array_key_exists('active', $data) && (bool) $group->get('active') !== $previousActive);
if ($aclFieldsChanged) {
CatalogAclCacheInvalidator::scheduleForModx($this->modx);
}

return Response::success(
$this->formatGroup($group),
$this->lexicon('ms3_customer_group_updated'),
Expand Down Expand Up @@ -247,6 +258,8 @@ public function delete(array $params = []): array
)->getData();
}

CatalogAclCacheInvalidator::scheduleForModx($this->modx);

return Response::success([], $this->lexicon('ms3_customer_group_deleted'))->getData();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use MiniShop3\Model\msCustomerGroup;
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Catalog\CatalogAclCacheInvalidator;
use MiniShop3\Services\Customer\AuthManager;
use MiniShop3\Services\Customer\CustomerAccess;
use MiniShop3\Services\Grid\ManagerListFilterPolicy;
Expand Down Expand Up @@ -175,6 +176,7 @@ public function update(array $data = []): array
}

$wasBlocked = (bool) $customer->get('is_blocked');
$previousCustomerGroupId = $customer->get('customer_group_id');

$allowedFields = ['first_name', 'last_name', 'email', 'phone', 'is_active', 'is_blocked', 'customer_group_id'];

Expand Down Expand Up @@ -223,6 +225,13 @@ public function update(array $data = []): array
$authManager->revokeTokens($customer);
}

if (
array_key_exists('customer_group_id', $data)
&& (int) ($customer->get('customer_group_id') ?? 0) !== (int) ($previousCustomerGroupId ?? 0)
) {
CatalogAclCacheInvalidator::scheduleForModx($this->modx);
}

return Response::success($this->formatCustomer($customer), 'Customer updated successfully')->getData();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public function process()
/** @var msCustomer $customer */
$customer = $authManager->validateToken($token, msCustomerToken::TYPE_PASSWORD_RESET);

if (!$customer) {
if (!$customer || CustomerAccess::isPasswordResetDenied($customer)) {
return $this->failure($this->modx->lexicon('ms3_customer_err_token_invalid'));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ public function __construct(
) {
}

public static function scheduleForModx(modX $modx): void
{
if (!$modx->services->has('ms3_catalog_acl_cache')) {
return;
}

$invalidator = $modx->services->get('ms3_catalog_acl_cache');
if ($invalidator instanceof self) {
$invalidator->schedule();
}
}

/**
* Defer {@see invalidate()} until after the current processor finishes.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,30 @@ public function apply(
}
}

/**
* Visibility SQL for the current HTTP visitor (Fenom snippets, same path as Web API #755).
* Member-aware results also disable MODX page cache for this request so member HTML
* is not stored under the anonymous resource cache key.
*
* @return non-empty-string|null
*/
public function buildWhereFragmentForRequest(
string $resourceAlias,
?string $contextKey = null,
): ?string {
$contextKey = $this->requestContextKey($contextKey);
$allowedIds = $this->customerResourceGroupResolver()->resolveAllowedIdsForRequest($contextKey);
$this->disablePageCacheForMemberCatalog($allowedIds);

return $this->buildWhereFragment($resourceAlias, $contextKey, $allowedIds);
}

/**
* Visibility SQL for pdoTools INNER JOIN ON / xPDO where.
*
* Fenom listings pass no allowed ids (anonymous-safe). Member-aware callers
* pass ids from {@see CustomerResourceGroupResolver} or use {@see applyForRequest()}.
* Empty $allowedResourceGroupIds → anonymous gate. Member-aware callers pass ids from
* {@see CustomerResourceGroupResolver} or use {@see buildWhereFragmentForRequest()} /
* {@see applyForRequest()}.
*
* @param list<int> $allowedResourceGroupIds
*
Expand Down Expand Up @@ -142,6 +161,41 @@ public function buildWhereFragment(
);
}

/**
* Single-resource visibility for the current HTTP visitor (#755 Fenom &product=).
* Member-aware lookups disable MODX page cache for this request (#755 cache key).
*/
public function isVisibleForRequest(
int $resourceId,
string $resourceAlias = 'msProduct',
?string $contextKey = null,
): bool {
$contextKey = $this->requestContextKey($contextKey);
$allowedIds = $this->customerResourceGroupResolver()->resolveAllowedIdsForRequest($contextKey);
$this->disablePageCacheForMemberCatalog($allowedIds);

return $this->isVisible($resourceId, $resourceAlias, $contextKey, $allowedIds);
}

/**
* MODX page cache keys are per-resource, not per visitor. Member-expanded HTML must
* not be written into that shared key (#755).
*
* @param list<int> $allowedResourceGroupIds
*/
private function disablePageCacheForMemberCatalog(array $allowedResourceGroupIds): void
{
if (self::positiveIntIds($allowedResourceGroupIds) === []) {
return;
}
// Fenom has a current resource; Web API / CLI often do not ($resource is null at runtime).
$resource = $this->modx->resource;
if (!is_object($resource)) {
return;
}
$resource->set('cacheable', 0);
}

/**
* Single-resource visibility (Fenom &product= and similar).
*
Expand All @@ -157,10 +211,7 @@ public function isVisible(
return false;
}

$contextKey ??= (string) ($this->modx->context->key ?? '');
if ($contextKey === '') {
$contextKey = 'web';
}
$contextKey = $this->requestContextKey($contextKey);

$fragment = $this->buildWhereFragment($resourceAlias, $contextKey, $allowedResourceGroupIds);
if ($fragment === null) {
Expand Down Expand Up @@ -195,6 +246,15 @@ private function customerResourceGroupResolver(): CustomerResourceGroupResolver
return new CustomerResourceGroupResolver($this->modx);
}

private function requestContextKey(?string $contextKey = null): string
{
if ($contextKey === null) {
$contextKey = trim((string) ($this->modx->context->key ?? ''));
}

return $contextKey === '' ? 'web' : $contextKey;
}

/**
* @param list<int> $allowedResourceGroupIds
* @param string $quotedPrincipalClass Already connection-quoted principal_class
Expand Down
Loading
Loading