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
5 changes: 5 additions & 0 deletions core/components/minishop3/config/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@
$controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx);
return $controller->filters($params);
});

$router->get('/{id}/images', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx);
return $controller->getImages($params);
});
});

// Public category catalog — no TokenMiddleware (headless nav / PLP)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public function __construct(modX $modx)
/**
* GET /api/v1/product/get/{id}
*
* Query: context, include_images (0|1, default 0 — omit images[]; name→alt, no DB alt).
*
* @param array<string, mixed> $params
*/
public function get(array $params = []): Response
Expand Down Expand Up @@ -59,7 +61,8 @@ public function get(array $params = []): Response
*
* Query: parent|category, parents, nested, price_min, price_max, in_stock, stock_min,
* vendor_id, new, popular, favorite, options (JSON),
* limit, offset|page, sort, dir, query, context, include_options, include_content
* limit, offset|page, sort, dir, query, context, include_options, include_content,
* include_images (0|1, default 0, cap 10 files per item)
*
* @param array<string, mixed> $params Route + query params (Router merges $_GET)
*/
Expand Down Expand Up @@ -99,6 +102,36 @@ public function filters(array $params = []): Response
return Response::success($result);
}

/**
* GET /api/v1/product/{id}/images
*
* Same gallery serializer as include_images=1 on get. 404 if the product is not storefront-visible.
*
* @param array<string, mixed> $params
*/
public function getImages(array $params = []): Response
{
$productId = (int) ($params['id'] ?? 0);

if ($productId <= 0) {
return Response::error(
$this->modx->lexicon('ms3_err_product_id_ns'),
HttpStatus::BAD_REQUEST
);
}

$result = $this->catalog()->getPublicImages($productId, $params);

if ($result === null) {
return Response::error(
$this->modx->lexicon('ms3_err_product_nf'),
HttpStatus::NOT_FOUND
);
}

return Response::success($result);
}

private function catalog(): ProductCatalogService
{
/** @var ProductCatalogService $service */
Expand Down
39 changes: 39 additions & 0 deletions core/components/minishop3/src/Middleware/TokenMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ class TokenMiddleware implements MiddlewareInterface
'/api/v1/health',
];

/**
* Glob patterns (`*` = one path segment). Keeps /{id}/images public without
* opening the whole `/api/v1/product/*` group via a prefix (#584).
*
* @var list<string>
*/
private array $publicRoutePatterns = [
'/api/v1/product/*/images',
];

/**
* @param modX $modx MODX instance
*/
Expand Down Expand Up @@ -196,15 +206,44 @@ private function isPublicRoute(string $uri): bool
$route = preg_replace('#^/assets/components/minishop3/api\.php#', '', $path);
}

$route = $this->normalizePublicPath((string) $route);

foreach ($this->publicRoutes as $publicRoute) {
if (str_starts_with($route, $publicRoute)) {
return true;
}
}

foreach ($this->publicRoutePatterns as $pattern) {
if (self::matchesSegmentPattern($route, $pattern)) {
return true;
}
}

return false;
}

private function normalizePublicPath(string $route): string
{
$qPos = strpos($route, '?');
if ($qPos !== false) {
$route = substr($route, 0, $qPos);
}

if ($route !== '/') {
$route = rtrim($route, '/');
}

return $route;
}

private static function matchesSegmentPattern(string $path, string $pattern): bool
{
$regex = '#^' . str_replace('\\*', '[^/]+', preg_quote($pattern, '#')) . '$#';

return (bool) preg_match($regex, $path);
}

/**
* Add public route
*
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ class ServiceRegistry
'class' => \MiniShop3\Services\Product\ProductFacetService::class,
'interface' => null,
],
'ms3_product_gallery_public' => [
'class' => \MiniShop3\Services\Product\ProductGalleryPublicService::class,
'interface' => null,
],
'ms3_category_catalog' => [
'class' => \MiniShop3\Services\Category\CategoryCatalogService::class,
'interface' => null,
Expand Down
1 change: 1 addition & 0 deletions core/components/minishop3/src/ServiceRegistryFactories.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public static function map(): array
'ms3_product_link_service' => $modxOnly(),
'ms3_product_catalog' => $modxOnly(),
'ms3_product_facets' => $modxOnly(),
'ms3_product_gallery_public' => $modxOnly(),
'ms3_category_catalog' => $modxOnly(),
'ms3_delivery_catalog' => $modxOnly(),
'ms3_payment_catalog' => $modxOnly(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ public static function whitelistPublicPayload(
array $payload,
bool $includeContent,
bool $includeOptions,
bool $includeImages = false,
): array {
$allowed = array_merge(self::RESOURCE_FIELDS, self::DATA_FIELDS);
if ($includeContent) {
Expand All @@ -147,6 +148,10 @@ public static function whitelistPublicPayload(
$result['options'] = self::stripOptionMetadata($payload['options']);
}

if ($includeImages && is_array($payload['images'] ?? null)) {
$result['images'] = ProductGalleryPublicSerializer::whitelistItems($payload['images']);
}

return $result;
}

Expand All @@ -158,10 +163,48 @@ public static function toBool(mixed $value): bool
/**
* Single published product by ID (same visibility rules as list).
*
* Query: context, include_images (0|1, default 0).
*
* @param array<string, mixed> $params Optional context override
* @return array<string, mixed>|null
*/
public function getById(int $productId, array $params = []): ?array
{
$product = $this->findVisibleProduct($productId, $params);
if ($product === null) {
return null;
}

$options = self::stripOptionMetadata(
$this->optionService()->loadOptionsForProduct($productId, false)
);

$includeImages = self::toBool($params['include_images'] ?? false);
$images = $includeImages ? $this->loadImagesForProduct($product) : null;

return $this->formatProduct($product, true, $options, $images);
}

/**
* Gallery only: same visibility as get. Always returns images[] (may be empty).
*
* @param array<string, mixed> $params
* @return array{images: list<array<string, mixed>>}|null
*/
public function getPublicImages(int $productId, array $params = []): ?array
{
$product = $this->findVisibleProduct($productId, $params);
if ($product === null) {
return null;
}

return ['images' => $this->loadImagesForProduct($product)];
}

/**
* @param array<string, mixed> $params
*/
private function findVisibleProduct(int $productId, array $params): ?msProduct
{
if ($productId <= 0) {
return null;
Expand All @@ -176,15 +219,46 @@ public function getById(int $productId, array $params = []): ?array
/** @var msProduct|null $product */
$product = $this->modx->getObject(msProduct::class, $this->publicCriteria($criteria));

if (!$product) {
return null;
}
return $product ?: null;
}

$options = self::stripOptionMetadata(
$this->optionService()->loadOptionsForProduct($productId, false)
/**
* @return list<array<string, mixed>>
*/
private function loadImagesForProduct(msProduct $product): array
{
$data = $product->loadData();
$previewFileId = $data
? $this->imageService()->resolvePreviewFileId($data)
: 0;

return $this->gallery()->loadForProduct(
(int) $product->get('id'),
(string) $product->get('pagetitle'),
$previewFileId,
);
}

/**
* @param list<msProduct> $products
* @return array<int, list<array<string, mixed>>>
*/
private function loadImagesForProducts(array $products): array
{
$meta = [];
foreach ($products as $product) {
$id = (int) $product->get('id');
if ($id <= 0) {
continue;
}
$data = $product->loadData();
$meta[$id] = [
'pagetitle' => (string) $product->get('pagetitle'),
'preview_file_id' => $data ? (int) $data->get('preview_file_id') : 0,
];
}

return $this->formatProduct($product, true, $options);
return $this->gallery()->loadForProducts($meta, ProductGalleryPublicSerializer::MAX_IMAGES_LIST);
}

/**
Expand All @@ -198,7 +272,7 @@ public function getById(int $productId, array $params = []): ?array
* - in_stock, stock_min, vendor_id, new, popular, favorite
* - options: JSON object or bracket map (AND between keys, OR within key)
* - limit, offset | page, sort, dir, query, context
* - include_options, include_content
* - include_options, include_content, include_images (default 0; cap 10 files / product)
*
* @param array<string, mixed> $params
* @return array{items: list<array<string, mixed>>, total: int, limit: int, offset: int}
Expand All @@ -213,6 +287,7 @@ public function getList(array $params): array
$offset = self::resolveOffset($params, $limit);
$includeOptions = self::toBool($params['include_options'] ?? false);
$includeContent = self::toBool($params['include_content'] ?? false);
$includeImages = self::toBool($params['include_images'] ?? false);

$total = $this->countList($params, $filters);

Expand All @@ -230,11 +305,16 @@ public function getList(array $params): array
? $this->loadOptionsForProducts($ids)
: [];

$galleries = ($includeImages && $ids !== [])
? $this->loadImagesForProducts($productList)
: [];

$items = [];
foreach ($productList as $product) {
$productId = (int) $product->get('id');
$options = $includeOptions ? ($optionsByProduct[$productId] ?? []) : null;
$items[] = $this->formatProduct($product, $includeContent, $options);
$images = $includeImages ? ($galleries[$productId] ?? []) : null;
$items[] = $this->formatProduct($product, $includeContent, $options, $images);
}

return [
Expand Down Expand Up @@ -439,12 +519,14 @@ private function optionService(): OptionService

/**
* @param array<string, mixed>|null $options null = omit options key; array = include
* @param list<array<string, mixed>>|null $images null = omit images key; array = include
* @return array<string, mixed>
*/
private function formatProduct(
msProduct $product,
bool $includeContent,
?array $options = null,
?array $images = null,
): array {
$data = $product->loadData();
$payload = [];
Expand Down Expand Up @@ -482,11 +564,36 @@ private function formatProduct(
$payload['options'] = $options;
}

if ($images !== null) {
$payload['images'] = $images;
}

$modified = $product->modifyFields($payload);
if (is_array($modified)) {
$payload = $modified;
}

return self::whitelistPublicPayload($payload, $includeContent, $options !== null);
return self::whitelistPublicPayload(
$payload,
$includeContent,
$options !== null,
$images !== null,
);
}

private function gallery(): ProductGalleryPublicService
{
/** @var ProductGalleryPublicService $service */
$service = $this->modx->services->get('ms3_product_gallery_public');

return $service;
}

private function imageService(): ProductImageService
{
/** @var ProductImageService $service */
$service = $this->modx->services->get('ms3_product_image');

return $service;
}
}
Loading