Skip to content
Open
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
11 changes: 11 additions & 0 deletions core/components/minishop3/config/routes/manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,17 @@
CategoryProductActionPermissions::mutationPermissions()
)
]);
// Category-scoped inline-edit product data (#455)
$router->put('/{id}/products/{productId}/data', function($params) use ($modx) {
$input = file_get_contents('php://input');
$data = json_decode($input, true) ?: [];
$allParams = array_merge($data, $_GET, $params);

$controller = new \MiniShop3\Controllers\Api\Manager\CategoryProductsController($modx);
return $controller->updateProductData($allParams);
}, [
new PermissionMiddleware($modx, 'msproduct_save')
]);
// Toggle product publish status
$router->post('/{id}/products/{productId}/publish', function($params) use ($modx) {
$input = file_get_contents('php://input');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Category\CategoryProductActionPermissions;
use MiniShop3\Services\Category\CategoryProductDocumentPolicy;
use MiniShop3\Services\Category\CategoryProductScopeService;
use MiniShop3\Services\Category\CategoryProductsListService;
use MiniShop3\Services\FilterConfigManager;
Expand Down Expand Up @@ -265,6 +266,76 @@ public function bulkDelete(array $params = []): array
return $this->multiple($params);
}

/**
* Update product data from category grid inline-edit
* PUT /api/mgr/categories/{id}/products/{productId}/data
*
* @param array $params
* @return array Response
*/
public function updateProductData(array $params = []): array
{
$categoryId = (int) ($params['id'] ?? 0);
$productId = (int) ($params['productId'] ?? 0);
$nested = filter_var($params['nested'] ?? false, FILTER_VALIDATE_BOOLEAN);

if (!$categoryId) {
return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData();
}

if (!$productId) {
return Response::error('Product ID is required', HttpStatus::BAD_REQUEST)->getData();
}

$data = $params;
unset($data['id'], $data['productId'], $data['nested']);

if ($data === []) {
return Response::error('Invalid request data', HttpStatus::BAD_REQUEST)->getData();
}

// Scope check + product fetch in one round-trip (CategoryProductScopePolicy).
// Replaces the separate isProductInCategoryScope() bool-only lookup and yields
// the product instance for the document ACL check below (#473 pattern).
$product = $this->scopeService()->findInCategory($categoryId, $productId, $nested);

if (!$product) {
$this->modx->lexicon->load('minishop3:default');

return Response::error(
$this->modx->lexicon('ms3_err_product_not_in_category_scope'),
HttpStatus::FORBIDDEN
)->getData();
}

$savePolicies = [CategoryProductDocumentPolicy::POLICY_SAVE];
if (!CategoryProductDocumentPolicy::isAllowedAll($product, $savePolicies)) {
$this->logDocumentPolicyDenied($product, $savePolicies);

return Response::error(
'Save permission denied for this document',
HttpStatus::FORBIDDEN
)->getData();
}

/** @var \MiniShop3\Services\Product\ProductDataService|null $productDataService */
$productDataService = $this->modx->services->get('ms3_product_data_service');
if (!$productDataService) {
return Response::error('Product data service is not available', HttpStatus::INTERNAL_SERVER_ERROR)->getData();
}

$result = $productDataService->updateProductData($productId, $data);

if (!empty($result['ok']) && !empty($result['data'])) {
return Response::success($result['data'])->getData();
}

$code = $result['code'] ?? HttpStatus::INTERNAL_SERVER_ERROR;
$message = $result['message'] ?? 'Failed to save product data';

return Response::error($message, $code)->getData();
}

/**
* Toggle product publish status
* POST /api/mgr/categories/{id}/products/{productId}/publish
Expand Down Expand Up @@ -331,6 +402,19 @@ private function scopeService(): CategoryProductScopeService
: new CategoryProductScopeService($this->modx);
}

/** @param list<string> $policies */
private function logDocumentPolicyDenied(object $product, array $policies): void
{
$this->modx->log(
modX::LOG_LEVEL_WARN,
'[CategoryProductsController] Document policy denied ('
. implode(',', $policies)
. ') for product '
. (int) $product->get('id')
. ' (user id ' . (int) ($this->modx->user->get('id') ?? 0) . ')'
);
}

private function denyWithoutPermission(string $permission): ?array
{
if ($this->modx->hasPermission($permission)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

namespace MiniShop3\Services\Category;

use MiniShop3\Model\msCategory;
use MiniShop3\Model\msProduct;
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MODX\Revolution\modX;

/**
* Resource-level MODX ACL (checkPolicy) for category product Manager API (#445).
*
* Complements global msproduct_* / view_document route permissions (#378).
*/
final class CategoryProductDocumentPolicy
{
public const POLICY_VIEW = 'view';
public const POLICY_SAVE = 'save';
public const POLICY_PUBLISH = 'publish';
public const POLICY_DELETE = 'delete';
public const POLICY_UNPUBLISH = 'unpublish';
public const POLICY_UNDELETE = 'undelete';

public static function categoryViewPolicy(): string
{
return self::POLICY_VIEW;
}

/** @return list<string> */
public static function sortPolicies(): array
{
return [self::POLICY_SAVE];
}

/** @return list<string> */
public static function policiesForPublish(bool $published): array
{
return $published
? [self::POLICY_PUBLISH]
: [self::POLICY_SAVE, self::POLICY_UNPUBLISH];
}

public static function isAllowed(object $resource, string $policy): bool
{
return self::evaluate($resource, $policy) === null;
}

/** @param list<string> $policies */
public static function isAllowedAll(object $resource, array $policies): bool
{
return self::evaluateAll($resource, $policies) === null;
}

/**
* @return array{status: int, message: string}|null null when allowed
*/
public static function evaluate(object $resource, string $policy): ?array
{
if (method_exists($resource, 'checkPolicy') && $resource->checkPolicy($policy)) {
return null;
}

return [
'status' => HttpStatus::FORBIDDEN,
'message' => self::messageForPolicy($policy),
];
}

/**
* @param list<string> $policies
* @return array{status: int, message: string}|null null when allowed
*/
public static function evaluateAll(object $resource, array $policies): ?array
{
foreach ($policies as $policy) {
$denied = self::evaluate($resource, $policy);
if ($denied !== null) {
return $denied;
}
}

return null;
}

public static function denialResponse(object $resource, string $policy): ?array
{
return self::toErrorResponse(self::evaluate($resource, $policy));
}

/** @param list<string> $policies */
public static function denialResponseAll(object $resource, array $policies): ?array
{
return self::toErrorResponse(self::evaluateAll($resource, $policies));
}

public static function canViewInCategoryGrid(modX $modx, msProduct $product, bool $nested): bool
{
if (!self::isAllowed($product, self::POLICY_VIEW)) {
return false;
}

if (!$nested) {
return true;
}

$parentId = (int) $product->get('parent');
if ($parentId <= 0) {
return false;
}

$parent = $modx->getObject(msCategory::class, $parentId);
if (!$parent instanceof msCategory) {
return false;
}

return self::isAllowed($parent, self::POLICY_VIEW);
}

/**
* @param array<int, msCategory> $parentsById
*/
public static function canViewInCategoryGridCached(
msProduct $product,
bool $nested,
array $parentsById,
): bool {
if (!self::isAllowed($product, self::POLICY_VIEW)) {
return false;
}

if (!$nested) {
return true;
}

$parentId = (int) $product->get('parent');
if ($parentId <= 0) {
return false;
}

$parent = $parentsById[$parentId] ?? null;
if (!$parent instanceof msCategory) {
return false;
}

return self::isAllowed($parent, self::POLICY_VIEW);
}

public static function toErrorResponse(?array $evaluation): ?array
{
if ($evaluation === null) {
return null;
}

return Response::error(
$evaluation['message'],
$evaluation['status']
)->getData();
}

private static function messageForPolicy(string $policy): string
{
return match ($policy) {
self::POLICY_VIEW => 'View permission denied for this document',
self::POLICY_SAVE => 'Save permission denied for this document',
self::POLICY_PUBLISH => 'Publish permission denied for this document',
self::POLICY_DELETE => 'Delete permission denied for this document',
self::POLICY_UNPUBLISH => 'Unpublish permission denied for this document',
self::POLICY_UNDELETE => 'Undelete permission denied for this document',
default => 'Access denied for this document',
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,30 @@ public static function isParentInScope(
return false;
}

return in_array(
$productParentId,
self::allowedParentCategoryIds($categoryId, $nested, $descendantCategoryIds),
true
);
}

/**
* @param list<int> $descendantCategoryIds Child category IDs (recursive, excluding root)
*
* @return list<int>
*/
public static function allowedParentCategoryIds(
int $categoryId,
bool $nested,
array $descendantCategoryIds
): array {
if (!$nested) {
return $productParentId === $categoryId;
return [$categoryId];
}

$allowed = $descendantCategoryIds;
$allowed[] = $categoryId;

return in_array($productParentId, $allowed, true);
return $allowed;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ public function findInCategory(int $categoryId, int $productId, bool $nested = f
return null;
}

$allowedParents = $this->treeService()->productParentIds($categoryId, $nested);

if ($allowedParents === []) {
return null;
}

if (!$nested) {
/** @var msProduct|null $product */
$product = $this->modx->getObject(msProduct::class, [
Expand All @@ -48,7 +42,17 @@ public function findInCategory(int $categoryId, int $productId, bool $nested = f
return null;
}

return in_array((int) $product->get('parent'), $allowedParents, true) ? $product : null;
$parentId = (int) $product->get('parent');
if ($parentId <= 0) {
return null;
}

return CategoryProductScopePolicy::isParentInScope(
$parentId,
$categoryId,
true,
$this->treeService()->getDescendantCategoryIds($categoryId)
) ? $product : null;
}

private function treeService(): CategoryTreeService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,11 @@ private function quoteOptionKeyForJoinCondition(string $key): string
*/
public function getAllowedProductParentCategoryIds(int $categoryId, bool $nested): array
{
if (!$nested) {
return [$categoryId];
}

$ids = $this->treeService()->getDescendantCategoryIds($categoryId);
$ids[] = $categoryId;

return $ids;
return CategoryProductScopePolicy::allowedParentCategoryIds(
$categoryId,
$nested,
$nested ? $this->treeService()->getDescendantCategoryIds($categoryId) : []
);
}

/**
Expand Down
Loading