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
10 changes: 10 additions & 0 deletions core/components/minishop3/config/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@

// Public catalog — no TokenMiddleware (headless storefront without customer session)
$router->group('/product', function ($router) use ($modx) {
$router->get('/get', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx);
return $controller->resolve($params);
});

$router->get('/get/{id}', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx);
return $controller->get($params);
Expand All @@ -293,6 +298,11 @@

// Public category catalog — no TokenMiddleware (headless nav / PLP)
$router->group('/category', function ($router) use ($modx) {
$router->get('/get', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\CategoryController($modx);
return $controller->resolve($params);
});

$router->get('/get/{id}', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\CategoryController($modx);
return $controller->get($params);
Expand Down
3 changes: 3 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@
$_lang['ms3_err_product_update_failed'] = 'Failed to update product';
$_lang['ms3_err_catalog_parents_invalid'] = 'Invalid parents filter';
$_lang['ms3_err_catalog_parents_limit'] = 'Too many parent category IDs';
$_lang['ms3_err_catalog_lookup_required'] = 'Provide exactly one of alias or uri.';
$_lang['ms3_err_catalog_lookup_conflict'] = 'Provide alias or uri, not both.';
$_lang['ms3_err_catalog_lookup_invalid'] = 'Invalid catalog lookup parameter.';
$_lang['ms3_err_catalog_price_invalid'] = 'Invalid price filter';
$_lang['ms3_err_catalog_price_range'] = 'price_max must be greater than or equal to price_min';
$_lang['ms3_err_catalog_stock_invalid'] = 'Invalid stock_min filter';
Expand Down
3 changes: 3 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@
$_lang['ms3_err_product_update_failed'] = 'Не удалось обновить товар';
$_lang['ms3_err_catalog_parents_invalid'] = 'Некорректный фильтр parents';
$_lang['ms3_err_catalog_parents_limit'] = 'Слишком много ID категорий в parents';
$_lang['ms3_err_catalog_lookup_required'] = 'Укажите ровно один параметр: alias или uri.';
$_lang['ms3_err_catalog_lookup_conflict'] = 'Укажите alias или uri, но не оба сразу.';
$_lang['ms3_err_catalog_lookup_invalid'] = 'Некорректный параметр поиска в каталоге.';
$_lang['ms3_err_catalog_price_invalid'] = 'Некорректный фильтр цены';
$_lang['ms3_err_catalog_price_range'] = 'price_max должен быть не меньше price_min';
$_lang['ms3_err_catalog_stock_invalid'] = 'Некорректный фильтр stock_min';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Catalog\CatalogResolve;
use MiniShop3\Services\Category\CategoryCatalogService;
use MODX\Revolution\modX;

Expand Down Expand Up @@ -52,6 +53,48 @@ public function get(array $params = []): Response
return Response::success($category);
}

/**
* GET /api/v1/category/get?alias=…|uri=…&context=…
*
* @param array<string, mixed> $params
*/
public function resolve(array $params = []): Response
{
$parsed = CatalogResolve::parseLookup(
$params,
(string) ($this->modx->context->key ?? 'web'),
);

if (!$parsed['ok']) {
$lexiconKey = match ($parsed['error']) {
'required' => 'ms3_err_catalog_lookup_required',
'conflict' => 'ms3_err_catalog_lookup_conflict',
'invalid' => 'ms3_err_catalog_lookup_invalid',
};

return Response::error(
$this->modx->lexicon($lexiconKey),
HttpStatus::BAD_REQUEST
);
}

$category = $this->catalog()->resolveByLookup(
$params,
$parsed['field'],
$parsed['value'],
$parsed['context'],
);

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

return Response::success($category);
}

/**
* GET /api/v1/category/list
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Catalog\CatalogResolve;
use MiniShop3\Services\Product\ProductCatalogFilterException;
use MiniShop3\Services\Product\ProductCatalogService;
use MiniShop3\Services\Product\ProductFacetService;
Expand Down Expand Up @@ -56,6 +57,48 @@ public function get(array $params = []): Response
return Response::success($product);
}

/**
* GET /api/v1/product/get?alias=…|uri=…&context=…
*
* @param array<string, mixed> $params
*/
public function resolve(array $params = []): Response
{
$parsed = CatalogResolve::parseLookup(
$params,
(string) ($this->modx->context->key ?? 'web'),
);

if (!$parsed['ok']) {
$lexiconKey = match ($parsed['error']) {
'required' => 'ms3_err_catalog_lookup_required',
'conflict' => 'ms3_err_catalog_lookup_conflict',
'invalid' => 'ms3_err_catalog_lookup_invalid',
};

return Response::error(
$this->modx->lexicon($lexiconKey),
HttpStatus::BAD_REQUEST
);
}

$product = $this->catalog()->resolveByLookup(
$params,
$parsed['field'],
$parsed['value'],
$parsed['context'],
);

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

return Response::success($product);
}

/**
* GET /api/v1/product/list
*
Expand Down
12 changes: 6 additions & 6 deletions core/components/minishop3/src/Middleware/TokenMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,17 @@ class TokenMiddleware implements MiddlewareInterface
{
private modX $modx;

/** @var list<string> Prefixes matched with str_starts_with; missing token skips auto-mint */
/** @var list<string> Prefixes matched exactly or as path segment; missing token skips auto-mint */
private array $publicRoutes = [
'/api/v1/product/get/',
'/api/v1/product/get',
'/api/v1/product/list',
'/api/v1/product/filters',
'/api/v1/category/get/',
'/api/v1/category/get',
'/api/v1/category/list',
'/api/v1/category/tree',
'/api/v1/delivery/get/',
'/api/v1/delivery/get',
'/api/v1/delivery/list',
'/api/v1/payment/get/',
'/api/v1/payment/get',
'/api/v1/payment/list',
'/api/v1/customer/token/get',
'/api/v1/customer/logout',
Expand Down Expand Up @@ -209,7 +209,7 @@ private function isPublicRoute(string $uri): bool
$route = $this->normalizePublicPath((string) $route);

foreach ($this->publicRoutes as $publicRoute) {
if (str_starts_with($route, $publicRoute)) {
if ($route === $publicRoute || str_starts_with($route, $publicRoute . '/')) {
return true;
}
}
Expand Down
196 changes: 196 additions & 0 deletions core/components/minishop3/src/Services/Catalog/CatalogResolve.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Catalog;

use MODX\Revolution\modX;

/**
* Lookup param parsing for public Web API catalog resolve (alias OR uri + context).
*/
final class CatalogResolve
{
private const MAX_CONTEXT_LENGTH = 100;

/**
* Return resource id when exactly one row matches $criteria; 0 or 2+ rows → null.
*
* Used for alias lookup and for each uri variant (unique match among visibility criteria).
*
* @param array<string, mixed> $criteria
*/
public static function findUniqueId(modX $modx, string $class, array $criteria): ?int
{
$query = $modx->newQuery($class, $criteria);
// Table alias for namespaced models is the short class name; FQCN breaks SELECT.
$query->select('id');
$query->limit(2);

if (!$query->prepare() || !$query->stmt->execute()) {
return null;
}

$ids = [];
while ($row = $query->stmt->fetch(\PDO::FETCH_ASSOC)) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}

return count($ids) === 1 ? $ids[0] : null;
}

/**
* @param array<string, mixed> $params
* @return array{ok: true, field: 'alias'|'uri', value: string, context: string}|array{ok: false, error: 'required'|'conflict'|'invalid'}
*/
public static function parseLookup(array $params, string $contextFallback = 'web'): array
{
foreach (['alias', 'uri', 'context'] as $key) {
if (array_key_exists($key, $params) && !self::isScalarLookupParam($params[$key])) {
return ['ok' => false, 'error' => 'invalid'];
}
}

$hasAlias = array_key_exists('alias', $params);
$hasUri = array_key_exists('uri', $params);

$aliasRaw = $hasAlias ? trim((string) $params['alias']) : '';
$uriRaw = $hasUri ? trim((string) $params['uri']) : '';

$aliasProvided = $hasAlias && $aliasRaw !== '';
$uriProvided = $hasUri && $uriRaw !== '';

if ($aliasProvided && $uriProvided) {
return ['ok' => false, 'error' => 'conflict'];
}

if (!$aliasProvided && !$uriProvided) {
return ['ok' => false, 'error' => 'required'];
}

$context = self::resolveLookupContext($params, $contextFallback);
if ($context === null) {
return ['ok' => false, 'error' => 'invalid'];
}

if ($aliasProvided) {
if (!self::isValidAlias($aliasRaw)) {
return ['ok' => false, 'error' => 'invalid'];
}

return [
'ok' => true,
'field' => 'alias',
'value' => $aliasRaw,
'context' => $context,
];
}

$uri = self::normalizeUri($uriRaw);
if ($uri === null) {
return ['ok' => false, 'error' => 'invalid'];
}

return [
'ok' => true,
'field' => 'uri',
'value' => $uri,
'context' => $context,
];
}

/**
* Trim, strip leading slash, reject unsafe path segments.
*/
public static function normalizeUri(string $uri): ?string
{
$uri = trim($uri);
if ($uri === '') {
return null;
}

$uri = ltrim($uri, '/');
if ($uri === '' || self::containsUriRejectPattern($uri)) {
return null;
}

return $uri;
}

/**
* Exact uri first, then slash variant (with or without trailing slash).
*
* @return list<string>
*/
public static function uriLookupVariants(string $normalizedUri): array
{
$variants = [$normalizedUri];

if (str_ends_with($normalizedUri, '/')) {
$trimmed = rtrim($normalizedUri, '/');
if ($trimmed !== '') {
$variants[] = $trimmed;
}
} else {
$variants[] = $normalizedUri . '/';
}

return array_values(array_unique($variants));
}

/**
* @param array<string, mixed> $params
*/
private static function resolveLookupContext(array $params, string $fallback): ?string
{
if (array_key_exists('context', $params)) {
$raw = trim((string) $params['context']);
if ($raw !== '') {
return self::sanitizeContext($raw);
}
}

$resolved = CatalogQuery::resolveContext($params, $fallback);

return self::sanitizeContext($resolved);
}

private static function isScalarLookupParam(mixed $value): bool
{
return is_string($value) || is_int($value);
}

private static function sanitizeContext(string $key): ?string
{
$key = trim($key);
if ($key === '' || strlen($key) > self::MAX_CONTEXT_LENGTH) {
return null;
}

if (!preg_match('/^[a-zA-Z0-9_-]+$/', $key)) {
return null;
}

if (str_starts_with(strtolower($key), 'mgr')) {
return null;
}

return $key;
}

private static function isValidAlias(string $alias): bool
{
return $alias !== '' && !self::containsUriRejectPattern($alias);
}

private static function containsUriRejectPattern(string $value): bool
{
return str_contains($value, '..')
|| str_contains($value, '://')
|| str_contains($value, "\0")
|| str_contains($value, '//');
}
}
Loading