diff --git a/system/backend/php/lib/HAXCMS.php b/system/backend/php/lib/HAXCMS.php index 7ee92d8e15..6400c96fb0 100755 --- a/system/backend/php/lib/HAXCMS.php +++ b/system/backend/php/lib/HAXCMS.php @@ -2604,6 +2604,38 @@ public function appJWTConnectionSettings($base = '/') }; $multisiteUrlName = $extractSiteNameFromPath($refererPath); $sitename = $multisiteUrlName; + // D6: single-site mode resolution mirroring Node connectionSettings.js + // (179-196). In single-site mode the referer has no /_sites// + // segment so $sitename is blank. Resolve from the single-site manifest + // (HAXCMS_ROOT/site.json) so the siteToken mints for user:sitename + // instead of 'user:' (which wouldn't validate against the site API). + if ($sitename === '') { + $singleSiteJsonPath = HAXCMS_ROOT . '/site.json'; + if (file_exists($singleSiteJsonPath)) { + $singleSiteJson = @file_get_contents($singleSiteJsonPath); + if ($singleSiteJson !== false) { + $singleSite = json_decode($singleSiteJson); + if ( + $singleSite && + isset($singleSite->metadata) && + isset($singleSite->metadata->site) && + isset($singleSite->metadata->site->name) && + is_string($singleSite->metadata->site->name) && + $singleSite->metadata->site->name !== '' + ) { + $sitename = (string) $singleSite->metadata->site->name; + } + else if ( + $singleSite && + isset($singleSite->name) && + is_string($singleSite->name) && + $singleSite->name !== '' + ) { + $sitename = (string) $singleSite->name; + } + } + } + } $requestTokenUser = $this->getRequestTokenUserName(); // user token includes user and site name of the request $siteToken = $this->getRequestToken($requestTokenUser . ':' . $sitename); diff --git a/system/backend/php/lib/routes/cloneSite.php b/system/backend/php/lib/routes/cloneSite.php index f43fbbef02..d9c2e3170c 100644 --- a/system/backend/php/lib/routes/cloneSite.php +++ b/system/backend/php/lib/routes/cloneSite.php @@ -38,7 +38,16 @@ public function cloneSite() { if (isset($this->params['user_token']) && $GLOBALS['HAXCMS']->validateRequestToken($this->params['user_token'], $GLOBALS['HAXCMS']->getActiveUserName())) { $site = $GLOBALS['HAXCMS']->loadSite($this->params['site']['name']); $siteDirectoryPath = $site->directory . '/' . $site->manifest->metadata->site->name; - $originalPathForReplacement = "/sites/" . $site->manifest->metadata->site->name . "/files/"; + $originalSiteName = $site->manifest->metadata->site->name; + // F6: build the file-path rewrite prefix from the configured basePath + + // sitesDirectory instead of hardcoding /sites//files/ (mirror Node + // cloneSite.js:99-155). Keep the legacy /sites/ prefix as a fallback + // source so existing paths that use it are still rewritten correctly. + $basePath = isset($GLOBALS['HAXCMS']->basePath) ? rtrim((string) $GLOBALS['HAXCMS']->basePath, '/') : ''; + $sitesDirectory = isset($GLOBALS['HAXCMS']->sitesDirectory) && $GLOBALS['HAXCMS']->sitesDirectory != '' + ? $GLOBALS['HAXCMS']->sitesDirectory : '_sites'; + $configuredSourcePrefix = $basePath . '/' . $sitesDirectory . '/' . $originalSiteName . '/files/'; + $legacySourcePrefix = '/sites/' . $originalSiteName . '/files/'; $cloneName = $GLOBALS['HAXCMS']->getUniqueName($site->name); // ensure the path to the new folder is valid // resolve symlinks so that mirror copies real contents instead of recreating links @@ -62,17 +71,20 @@ public function cloneSite() { $site->manifest->metadata->site->name = $cloneName; $site->manifest->id = $GLOBALS['HAXCMS']->generateUUID(); // loop through all items and rewrite the path to files as we cloned it + $targetPrefix = $basePath . '/' . $sitesDirectory . '/' . $cloneName . '/files/'; foreach ($site->manifest->items as $delta => $item) { if (isset($item->metadata->files)) { foreach ($item->metadata->files as $delta2 => $file) { + // F6: replace both the configured prefix and the legacy /sites/ + // prefix with the configured target prefix. $site->manifest->items[$delta]->metadata->files[$delta2]->path = str_replace( - $originalPathForReplacement, - '/sites/' . $cloneName . '/files/', + array($configuredSourcePrefix, $legacySourcePrefix), + $targetPrefix, $site->manifest->items[$delta]->metadata->files[$delta2]->path ); $site->manifest->items[$delta]->metadata->files[$delta2]->fullUrl = str_replace( - $originalPathForReplacement, - '/sites/' . $cloneName . '/files/', + array($configuredSourcePrefix, $legacySourcePrefix), + $targetPrefix, $site->manifest->items[$delta]->metadata->files[$delta2]->fullUrl ); } diff --git a/system/backend/php/lib/routes/listSites.php b/system/backend/php/lib/routes/listSites.php index 1d698ef38f..63ea0485dd 100644 --- a/system/backend/php/lib/routes/listSites.php +++ b/system/backend/php/lib/routes/listSites.php @@ -30,10 +30,14 @@ public function listSites() { if ($item != "." && $item != ".." && is_dir(HAXCMS_ROOT . '/' . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item) && file_exists(HAXCMS_ROOT . '/' . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/site.json')) { $json = file_get_contents(HAXCMS_ROOT . '/' . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/site.json'); $site = json_decode($json); - if (isset($site->title)) { + // F6: don't filter by title (Node listSites.js includes all valid + // sites). Just verify the json_decode produced a valid site object. + if ($site && is_object($site)) { $site->location = $GLOBALS['HAXCMS']->basePath . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/'; $site->slug = $GLOBALS['HAXCMS']->basePath . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/'; - $site->metadata->pageCount = count($site->items); + if (isset($site->metadata) && is_object($site->metadata)) { + $site->metadata->pageCount = isset($site->items) && is_array($site->items) ? count($site->items) : 0; + } // we don't need all items stored here unset($site->items); $return['items'][] = $site; diff --git a/system/backend/php/lib/routes/login.php b/system/backend/php/lib/routes/login.php index 88e8784655..aa1fe43bbb 100644 --- a/system/backend/php/lib/routes/login.php +++ b/system/backend/php/lib/routes/login.php @@ -110,9 +110,11 @@ private function processCredentialLogin($u, $p, $legacy = false) { $entry = $this->registerFailedLoginAttempt($entry, $nowMs, $settings); $this->saveLoginAttemptEntry($attemptKey, $entry, $settings); } + // D2/Q8: login failure returns 401 (not 403) per spec; invalid-bearer + // on protected routes stays 403, but credential login failure is 401. return array( '__failed' => array( - 'status' => 403, + 'status' => 401, 'message' => 'Access denied', ) ); @@ -167,26 +169,40 @@ public function login() { if (isset($this->params['username']) && isset($this->params['password'])) { return $this->processCredentialLogin($this->params['username'], $this->params['password'], false); } - //old way - // if we don't have a user and the don't answer, bail - else if (isset($this->params['u']) && isset($this->params['p'])) { - return $this->processCredentialLogin($this->params['u'], $this->params['p'], true); - } - // login end point requested yet a jwt already exists - // this is something of a revalidate case + // D2/Q7: login end point requested yet a jwt already exists — this is a + // revalidate case. The body jwt was previously stripped in the session v1 + // handler; it now reaches this branch so JWT-revalidate works in v1. + // D2/Q8: use validateJWT(false) so an invalid jwt returns a 401 envelope + // instead of exiting with 403. Set sessionJwt from the body jwt first so + // validateJWT can decode it (sessionJwt is normally set from the bearer + // header in the HAXCMS constructor). else if (isset($this->params['jwt'])) { + $bodyJwt = is_string($this->params['jwt']) ? trim($this->params['jwt']) : ''; + if ($bodyJwt !== '') { + $GLOBALS['HAXCMS']->sessionJwt = $bodyJwt; + } + $valid = $GLOBALS['HAXCMS']->validateJWT(false); + if ($valid) { + return array( + "status" => 200, + "jwt" => $GLOBALS['HAXCMS']->getJWT($GLOBALS['HAXCMS']->getActiveUserName()), + ); + } return array( - "status" => 200, - "jwt" => $GLOBALS['HAXCMS']->validateJWT(), + '__failed' => array( + 'status' => 401, + 'message' => 'Invalid token', + ) ); } else { + // D2/Q8: login required returns 401 (not 403) per spec. return array( '__failed' => array( - 'status' => 403, + 'status' => 401, 'message' => 'Login is required', ) ); - } + } } } diff --git a/system/backend/php/lib/routes/saveOutline.php b/system/backend/php/lib/routes/saveOutline.php index 1b7cbe38d6..218917e60b 100644 --- a/system/backend/php/lib/routes/saveOutline.php +++ b/system/backend/php/lib/routes/saveOutline.php @@ -41,7 +41,7 @@ public function saveOutline() { return array( '__failed' => array( 'status' => 400, - 'message' => 'Missing outline items payload', + 'message' => 'Outline payload requires an items array', ) ); } diff --git a/system/backend/php/lib/siteRoutes/SiteApiRouter.php b/system/backend/php/lib/siteRoutes/SiteApiRouter.php index b035b6aab7..b3adabc07a 100644 --- a/system/backend/php/lib/siteRoutes/SiteApiRouter.php +++ b/system/backend/php/lib/siteRoutes/SiteApiRouter.php @@ -7,6 +7,15 @@ class SiteApiRouter { public static function dispatch($site) { + // C1: startup mutation-security guard mirroring Node + // assertSiteApiMutationRoutesAreSecured (app.js:1829). Runs once per + // process so a misconfigured spec (a mutation route resolving to + // public) is surfaced via error_log without blocking request handling. + static $mutationGuardChecked = false; + if (!$mutationGuardChecked) { + $mutationGuardChecked = true; + SiteRouteUtils::assertSiteApiMutationRoutesAreSecured(SiteRoutesMap::getRoutesMap()); + } $context = SiteApiRequestContext::fromSite($site); if (!$context->isSiteApiRequest()) { return false; @@ -82,9 +91,13 @@ public static function dispatch($site) ); return true; } + // C1: pass the matched route PATTERN (e.g. v1/items/:idOrSlug) so the + // spec-driven auth policy reader can look up the security declaration + // by route key. Mirrors Node validateSiteApiRouteAccess(req, siteRoute) + // where siteRoute is the Express route pattern, not the concrete path. $authResult = SiteApiSecurity::validateSiteApiAccess( $context, - is_string($context->routeSuffix) ? $context->routeSuffix : '', + isset($match['route']) ? $match['route'] : '', $context->method ); if (!$authResult['allowed']) { @@ -140,6 +153,7 @@ public static function matchRoute($routeSuffix, $routes = array()) return array( 'file' => $routeFile, 'params' => $matchedParams, + 'route' => $pattern, ); } $fallbackMatch = self::matchSpecialCaseRoutes($targetRoute, $routes); @@ -200,6 +214,7 @@ private static function matchSpecialCaseRoutes($targetRoute, $routes = array()) return array( 'file' => $routes[$case['route']], 'params' => $params, + 'route' => $case['route'], ); } return null; diff --git a/system/backend/php/lib/siteRoutes/SiteApiSecurity.php b/system/backend/php/lib/siteRoutes/SiteApiSecurity.php index db12bff93a..d387020033 100644 --- a/system/backend/php/lib/siteRoutes/SiteApiSecurity.php +++ b/system/backend/php/lib/siteRoutes/SiteApiSecurity.php @@ -110,51 +110,56 @@ public static function validateSiteApiAccess($context, $routeSuffix = '', $metho $result['message'] = ''; return $result; } + if ($policy === 'authenticated-user') { + // C1: spec-driven user-token enforcement for site API routes that + // declare userTokenHeader. Mirrors Node validateSiteApiRouteAccess + // authenticated-user branch (app.js:2328-2354). + $userToken = null; + if ( + isset($context) && + is_object($context) && + method_exists($context, 'getHeader') + ) { + $userToken = $context->getHeader('X-HAXCMS-User-Token'); + } + if (is_null($userToken) || $userToken === '') { + $result['status'] = 403; + $result['message'] = 'X-HAXCMS-User-Token header is required for this endpoint'; + return $result; + } + $validUserToken = false; + if ( + isset($GLOBALS['HAXCMS']) && + is_object($GLOBALS['HAXCMS']) && + method_exists($GLOBALS['HAXCMS'], 'validateRequestToken') + ) { + $validUserToken = $GLOBALS['HAXCMS']->validateRequestToken($userToken, $userName); + } + if (!$validUserToken) { + $result['status'] = 403; + $result['message'] = 'Invalid X-HAXCMS-User-Token header'; + return $result; + } + $result['allowed'] = true; + $result['status'] = 200; + $result['message'] = ''; + return $result; + } return $result; } private static function getRoutePolicy($routeSuffix, $method) { - $suffix = trim((string) $routeSuffix, '/'); $upperMethod = strtoupper((string) $method); if ($upperMethod === 'OPTIONS') { return 'public'; } - if (in_array($upperMethod, array('POST', 'PATCH', 'PUT', 'DELETE'), true)) { - return 'authenticated-site'; - } - $publicPatterns = array( - '/^$/', - '/^openapi(\\.json|\\.yaml)?$/', - '/^v1$/', - '/^v1\\/openapi(\\.json|\\.yaml)?$/', - '/^v1\\/site$/', - '/^v1\/items$/', - '/^v1\/items\/[^\/]+$/', - '/^v1\/content$/', - '/^v1\/content\/[^\/]+$/', - '/^v1\/tags$/', - '/^v1\/search$/', - '/^v1\/custom-elements/', - '/^v1\/blocks/', - '/^v1\/regions/', - '/^v1\/themes/', - '/^v1\/analytics$/', - '/^v1\/views/', - '/^v1\/displays/', - '/^v1\/entities$/', - '/^v1\/schemas$/', - '/^v1\/site\/export\/[^\/]+$/', - '/^v1\/items\/[^\/]+\/export\/[^\/]+$/', - ); - foreach ($publicPatterns as $pattern) { - if (preg_match($pattern, $suffix)) { - return 'public'; - } - } - if (preg_match('/^v1\/items\/[^\/]+\/revisions/', $suffix)) { - return 'authenticated-site'; - } - return 'authenticated'; + // C1/Q6: spec-driven auth policy. Read the security declaration from + // site-spec.yaml at runtime and fail-closed to 'authenticated' for any + // route not declared in the spec. Mirrors Node getSiteApiRouteAuthPolicy + // (app.js:1709). This resolves A1 structurally: files/reports GETs + // inherit bearer+siteToken from the spec (authenticated-site) instead of + // the old regex table which left them as bare 'authenticated'. + return SiteRouteUtils::getSiteApiRouteAuthPolicy($routeSuffix, $upperMethod); } private static function resolveBearerUserName() { diff --git a/system/backend/php/lib/siteRoutes/SiteRouteUtils.php b/system/backend/php/lib/siteRoutes/SiteRouteUtils.php index d64b1c6007..af8c778cbf 100644 --- a/system/backend/php/lib/siteRoutes/SiteRouteUtils.php +++ b/system/backend/php/lib/siteRoutes/SiteRouteUtils.php @@ -865,6 +865,262 @@ public static function parseYaml($yamlText = '') } return null; } + /** + * Normalize an OpenAPI security config array into a policy string. + * Mirrors Node normalizeSiteApiSecurityPolicy (app.js:1622): + * - empty array or requirement with no keys => 'public' + * - requirement with siteTokenHeader => 'authenticated-site' + * - requirement with userTokenHeader => 'authenticated-user' (precedence over bearer) + * - requirement with bearerAuth => 'authenticated' + * - default => 'public' + */ + public static function normalizeSecurityPolicy($securityConfig) + { + if (!is_array($securityConfig) || count($securityConfig) === 0) { + return 'public'; + } + $requiresBearer = false; + $requiresUserToken = false; + foreach ($securityConfig as $requirement) { + if (!is_array($requirement)) { + continue; + } + if (count($requirement) === 0) { + return 'public'; + } + if (array_key_exists('siteTokenHeader', $requirement)) { + return 'authenticated-site'; + } + if (array_key_exists('userTokenHeader', $requirement)) { + $requiresUserToken = true; + } + if (array_key_exists('bearerAuth', $requirement)) { + $requiresBearer = true; + } + } + if ($requiresUserToken) { + return 'authenticated-user'; + } + if ($requiresBearer) { + return 'authenticated'; + } + return 'public'; + } + /** + * Convert an OpenAPI path (/x/api/v1/.../{param}) to the PHP site route-key + * form (v1/.../:param) used by SiteRoutesMap. Mirrors Node + * convertOpenApiPathToSiteRoute (app.js:1612) but preserves the v1/ prefix + * to match PHP route map keys. + */ + public static function convertOpenApiPathToSiteRoute($openApiPath) + { + $route = (string) $openApiPath; + if (strpos($route, '/x/api') !== 0) { + return ''; + } + $route = preg_replace('#^/x/api/?#', '', $route); + $route = preg_replace('#^/#', '', $route); + $route = preg_replace('/\{([A-Za-z0-9_]+)\}/', ':$1', $route); + return is_string($route) ? $route : ''; + } + /** + * Convert an OpenAPI path (/system/api/v1/.../{param}) to the PHP system + * route-key form (v1/.../:param) used by SystemRoutesMap. Mirrors Node + * convertOpenApiPathToSystemRoute (app.js:1724) but preserves the v1/ + * prefix to match PHP route map keys. + */ + public static function convertOpenApiPathToSystemRoute($openApiPath) + { + $route = (string) $openApiPath; + if (strpos($route, '/system/api/v1') !== 0) { + return ''; + } + $route = preg_replace('#^/system/api/?#', '', $route); + $route = preg_replace('#^/#', '', $route); + $route = preg_replace('/\{([A-Za-z0-9_]+)\}/', ':$1', $route); + return is_string($route) ? $route : ''; + } + private static $siteApiAuthPolicies = null; + /** + * Read site-spec.yaml once per request and build a map of + * 'method:routeKey => policy' mirroring Node + * readSiteApiAuthPoliciesFromOpenApiSpec (app.js:1658). Cached in a + * static property so repeated lookups in the same request skip re-parsing. + */ + public static function readSiteApiAuthPoliciesFromOpenApiSpec() + { + if (self::$siteApiAuthPolicies !== null) { + return self::$siteApiAuthPolicies; + } + $policies = array(); + $specPath = dirname(__FILE__) . '/openapi/site-spec.yaml'; + if (!file_exists($specPath)) { + self::$siteApiAuthPolicies = $policies; + return $policies; + } + $specContents = file_get_contents($specPath); + if (!is_string($specContents) || $specContents === '') { + self::$siteApiAuthPolicies = $policies; + return $policies; + } + $parsedSpec = self::parseYaml($specContents); + if (!is_array($parsedSpec) || !isset($parsedSpec['paths']) || !is_array($parsedSpec['paths'])) { + self::$siteApiAuthPolicies = $policies; + return $policies; + } + $methods = array('get', 'post', 'put', 'patch', 'delete', 'options', 'head'); + foreach ($parsedSpec['paths'] as $openApiPath => $pathConfig) { + if (strpos((string) $openApiPath, '/x/api') !== 0) { + continue; + } + $routeKey = self::convertOpenApiPathToSiteRoute($openApiPath); + if ($routeKey === '') { + continue; + } + if (!is_array($pathConfig)) { + continue; + } + $pathLevelPolicy = self::normalizeSecurityPolicy( + isset($pathConfig['security']) ? $pathConfig['security'] : null + ); + foreach ($methods as $method) { + if (!array_key_exists($method, $pathConfig)) { + continue; + } + $operation = $pathConfig[$method]; + if (!is_array($operation)) { + continue; + } + $policy = $pathLevelPolicy; + if (array_key_exists('security', $operation)) { + $policy = self::normalizeSecurityPolicy($operation['security']); + } + $policies[$method . ':' . $routeKey] = $policy; + } + } + self::$siteApiAuthPolicies = $policies; + return $policies; + } + private static $systemApiAuthPolicies = null; + /** + * Read system-spec.yaml once per request and build a map of + * 'method:routeKey => policy' mirroring Node + * readSystemApiAuthPoliciesFromOpenApiSpec (app.js:1734). + */ + public static function readSystemApiAuthPoliciesFromOpenApiSpec() + { + if (self::$systemApiAuthPolicies !== null) { + return self::$systemApiAuthPolicies; + } + $policies = array(); + $specPath = dirname(__FILE__) . '/../systemRoutes/openapi/system-spec.yaml'; + if (!file_exists($specPath)) { + self::$systemApiAuthPolicies = $policies; + return $policies; + } + $specContents = file_get_contents($specPath); + if (!is_string($specContents) || $specContents === '') { + self::$systemApiAuthPolicies = $policies; + return $policies; + } + $parsedSpec = self::parseYaml($specContents); + if (!is_array($parsedSpec) || !isset($parsedSpec['paths']) || !is_array($parsedSpec['paths'])) { + self::$systemApiAuthPolicies = $policies; + return $policies; + } + $methods = array('get', 'post', 'put', 'patch', 'delete', 'options', 'head'); + foreach ($parsedSpec['paths'] as $openApiPath => $pathConfig) { + if (strpos((string) $openApiPath, '/system/api/v1') !== 0) { + continue; + } + $routeKey = self::convertOpenApiPathToSystemRoute($openApiPath); + if ($routeKey === '') { + continue; + } + if (!is_array($pathConfig)) { + continue; + } + $pathLevelPolicy = self::normalizeSecurityPolicy( + isset($pathConfig['security']) ? $pathConfig['security'] : null + ); + foreach ($methods as $method) { + if (!array_key_exists($method, $pathConfig)) { + continue; + } + $operation = $pathConfig[$method]; + if (!is_array($operation)) { + continue; + } + $policy = $pathLevelPolicy; + if (array_key_exists('security', $operation)) { + $policy = self::normalizeSecurityPolicy($operation['security']); + } + $policies[$method . ':' . $routeKey] = $policy; + } + } + self::$systemApiAuthPolicies = $policies; + return $policies; + } + /** + * Look up the spec-driven auth policy for a site API route+method. + * Fail-closed to 'authenticated' for any route not declared in the spec. + * Mirrors Node getSiteApiRouteAuthPolicy (app.js:1709). + */ + public static function getSiteApiRouteAuthPolicy($route, $method) + { + $policies = self::readSiteApiAuthPoliciesFromOpenApiSpec(); + $lookupKey = strtolower((string) $method) . ':' . (string) $route; + if (array_key_exists($lookupKey, $policies)) { + return $policies[$lookupKey]; + } + return 'authenticated'; + } + /** + * Look up the spec-driven auth policy for a system API route+method. + * Fail-closed to 'authenticated' for any route not declared in the spec. + * Mirrors Node getSystemApiRouteAuthPolicy (app.js:1785). + */ + public static function getSystemApiRouteAuthPolicy($route, $method) + { + $policies = self::readSystemApiAuthPoliciesFromOpenApiSpec(); + $lookupKey = strtolower((string) $method) . ':' . (string) $route; + if (array_key_exists($lookupKey, $policies)) { + return $policies[$lookupKey]; + } + return 'authenticated'; + } + /** + * Startup mutation-security guard mirroring Node + * assertSiteApiMutationRoutesAreSecured (app.js:1829). Checks that every + * non-GET/HEAD/OPTIONS route in the site route map resolves to a non-public + * auth policy via the spec-driven reader. Logs offending routes via + * error_log so a misconfigured spec is surfaced without blocking boot. + */ + public static function assertSiteApiMutationRoutesAreSecured($routesMap = null) + { + $registry = is_array($routesMap) ? $routesMap : array(); + $offendingRoutes = array(); + foreach ($registry as $method => $routeMap) { + $lowerMethod = strtolower((string) $method); + if ($lowerMethod === 'get' || $lowerMethod === 'head' || $lowerMethod === 'options') { + continue; + } + if (!is_array($routeMap)) { + continue; + } + foreach (array_keys($routeMap) as $route) { + if (self::getSiteApiRouteAuthPolicy($route, $method) === 'public') { + $offendingRoutes[] = strtoupper((string) $method) . ' ' . (string) $route; + } + } + } + if (count($offendingRoutes) > 0) { + error_log( + 'SECURITY: site API mutation routes resolve to a public auth policy and must declare security in site-spec.yaml: ' . implode(', ', $offendingRoutes) + ); + } + return $offendingRoutes; + } public static function getItemLookupValue($item) { if (isset($item) && isset($item->slug) && $item->slug != '') { diff --git a/system/backend/php/lib/siteRoutes/SiteRoutesMap.php b/system/backend/php/lib/siteRoutes/SiteRoutesMap.php index 74cdd1455c..587cc238e4 100644 --- a/system/backend/php/lib/siteRoutes/SiteRoutesMap.php +++ b/system/backend/php/lib/siteRoutes/SiteRoutesMap.php @@ -18,10 +18,6 @@ public static function getRoutesMap() 'openapi' => dirname(__FILE__) . '/discovery/openapi.php', 'openapi.json' => dirname(__FILE__) . '/discovery/openapi.php', 'openapi.yaml' => dirname(__FILE__) . '/discovery/openapi.php', - 'v1' => dirname(__FILE__) . '/discovery/api.php', - 'v1/openapi' => dirname(__FILE__) . '/discovery/openapi.php', - 'v1/openapi.json' => dirname(__FILE__) . '/discovery/openapi.php', - 'v1/openapi.yaml' => dirname(__FILE__) . '/discovery/openapi.php', 'v1/site' => dirname(__FILE__) . '/v1/site.php', 'v1/site/export/:format' => dirname(__FILE__) . '/v1/exports.php', 'v1/entities' => dirname(__FILE__) . '/v1/entities.php', diff --git a/system/backend/php/lib/siteRoutes/openapi/site-spec.yaml b/system/backend/php/lib/siteRoutes/openapi/site-spec.yaml index 0209cb2349..65cc010242 100644 --- a/system/backend/php/lib/siteRoutes/openapi/site-spec.yaml +++ b/system/backend/php/lib/siteRoutes/openapi/site-spec.yaml @@ -1214,6 +1214,7 @@ paths: - $ref: "#/components/parameters/FilterExtension" - $ref: "#/components/parameters/FilterStartsWith" - $ref: "#/components/parameters/FilterNameContains" + - $ref: "#/components/parameters/FileName" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" - $ref: "#/components/parameters/Sort" @@ -2188,6 +2189,16 @@ components: required: false schema: type: string + FileName: + name: filename + in: query + required: false + schema: + type: string + description: > + Substring filter applied to both the file relative path and the file + name. When supplied, only files whose path or name contains the value + (case-insensitive) are returned. FilterKind: name: filter.kind in: query diff --git a/system/backend/php/lib/siteRoutes/v1/blocks.php b/system/backend/php/lib/siteRoutes/v1/blocks.php index 6bb68f21d8..9e6e054f62 100644 --- a/system/backend/php/lib/siteRoutes/v1/blocks.php +++ b/system/backend/php/lib/siteRoutes/v1/blocks.php @@ -370,7 +370,9 @@ ); return; } - $filteredItems = SiteRouteUtils::applyItemFilters(SiteRouteUtils::getOrderedItems($site), $site); + // A4: pass $context so anon-visibility filtering fires (mirror Node + // blocks.js:502-504). + $filteredItems = SiteRouteUtils::applyItemFilters(SiteRouteUtils::getOrderedItems($site), $site, $context); $usageTotals = SiteRouteUtils::collectCustomElementUsage($site, $filteredItems); $known = $wcMapHasTag($wcMap, $webcomponentName) || array_key_exists($webcomponentName, $usageTotals) || @@ -407,7 +409,9 @@ return; } if ($webcomponentName != '') { - $orderedItems = SiteRouteUtils::getOrderedItems($site); + // A4: apply anon-visibility filtering so anon callers don't see usage + // details from unpublished/hidden items (mirror Node blocks.js:428-430). + $orderedItems = SiteRouteUtils::applyItemFilters(SiteRouteUtils::getOrderedItems($site), $site, $context); $usageDetails = $buildBlockUsageDetails($orderedItems, $webcomponentName); $usageItemIds = array(); $usageCount = 0; @@ -448,7 +452,9 @@ ); return; } - $filteredItems = SiteRouteUtils::applyItemFilters(SiteRouteUtils::getOrderedItems($site), $site); + // A4: pass $context so anon-visibility filtering fires (mirror Node + // blocks.js:345-347). + $filteredItems = SiteRouteUtils::applyItemFilters(SiteRouteUtils::getOrderedItems($site), $site, $context); $usage = SiteRouteUtils::collectCustomElementUsage($site, $filteredItems); $filterTag = strtolower(trim((string) SiteRouteUtils::getQueryValue('filter.tag', ''))); $tagSet = array(); diff --git a/system/backend/php/lib/siteRoutes/v1/contentMutation.php b/system/backend/php/lib/siteRoutes/v1/contentMutation.php index f4c8aea5d9..3c7d659e9a 100644 --- a/system/backend/php/lib/siteRoutes/v1/contentMutation.php +++ b/system/backend/php/lib/siteRoutes/v1/contentMutation.php @@ -70,7 +70,50 @@ if (!isset($body['node']) || !is_array($body['node'])) { $body['node'] = array(); } + // B2: map top-level body/content/schema/details into node.* mirroring + // Node content.js updateContent (282-329). Spec-conformant requests + // that send top-level fields now work instead of silently no-op'ing. + $bodyContent = ''; + if (isset($body['body']) && is_string($body['body'])) { + $bodyContent = $body['body']; + } + else if (isset($body['content']) && is_string($body['content'])) { + $bodyContent = $body['content']; + } + else if (isset($body['node']['body']) && is_string($body['node']['body'])) { + $bodyContent = $body['node']['body']; + } + if ($bodyContent === '') { + SiteRouteUtils::sendFormattedResponse( + array( + 'message' => 'Content body is required', + ), + array( + 'statusCode' => 400, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + $context->routeSuffix, + $context->apiBasePath + ); + return; + } + $schema = array(); + if (isset($body['schema']) && is_array($body['schema'])) { + $schema = $body['schema']; + } + else if (isset($body['node']['schema']) && is_array($body['node']['schema'])) { + $schema = $body['node']['schema']; + } $body['node']['id'] = (string) $resolvedItem->id; + $body['node']['body'] = $bodyContent; + $body['node']['schema'] = $schema; + if ( + array_key_exists('details', $body) && + is_array($body['details']) + ) { + $body['node']['details'] = $body['details']; + } $operations->params = $body; $operations->rawParams = $body; $result = $operations->saveNode(); diff --git a/system/backend/php/lib/siteRoutes/v1/exports.php b/system/backend/php/lib/siteRoutes/v1/exports.php index fe8e5e2a3e..bb79362263 100644 --- a/system/backend/php/lib/siteRoutes/v1/exports.php +++ b/system/backend/php/lib/siteRoutes/v1/exports.php @@ -98,7 +98,7 @@ if (!in_array($format, $SITE_EXPORT_FORMATS, true)) { $sendTopLevelError( 400, - 'Unsupported site export format \"' . $format . '\"', + 'Unsupported site export format "' . $format . '"', array('supportedFormats' => $SITE_EXPORT_FORMATS) ); return; @@ -167,7 +167,7 @@ $idOrSlug = isset($context->params['idOrSlug']) ? (string) $context->params['idOrSlug'] : ''; $item = SiteRouteUtils::findItemByIdOrSlug($site, $idOrSlug); if (!$item) { - $sendTopLevelError(404, 'Item not found for idOrSlug \"' . $idOrSlug . '\"'); + $sendTopLevelError(404, 'Item not found for idOrSlug "' . $idOrSlug . '"'); return; } if (SiteRouteUtils::isAnonymousSiteApiRequest($context) && !SiteRouteUtils::isItemVisibleToAnonymous($item)) { @@ -177,7 +177,7 @@ if (!in_array($format, $ITEM_EXPORT_FORMATS, true)) { $sendTopLevelError( 400, - 'Unsupported item export format \"' . $format . '\"', + 'Unsupported item export format "' . $format . '"', array('supportedFormats' => $ITEM_EXPORT_FORMATS) ); return; diff --git a/system/backend/php/lib/siteRoutes/v1/filesMutation.php b/system/backend/php/lib/siteRoutes/v1/filesMutation.php index f91077a2b3..ed7c0d80e4 100644 --- a/system/backend/php/lib/siteRoutes/v1/filesMutation.php +++ b/system/backend/php/lib/siteRoutes/v1/filesMutation.php @@ -153,28 +153,81 @@ function haxcmsResolveRequestedFilePathFromUuid($context, $fileUuid = '') $body['operation'] = 'delete'; } } - if (!isset($body['path']) || $body['path'] === '') { - if ($fileUuid !== '') { - $resolvedPath = haxcmsResolveRequestedFilePathFromUuid($context, $fileUuid); - // D52: reject non-UUID tokens with a 400 error (Node canonical) - if ($resolvedPath === false) { - SiteRouteUtils::sendFormattedResponse( - array( - 'message' => 'File uuid is required and must be a valid UUID', - ), - array( - 'statusCode' => 400, - 'allowedFormats' => array('json'), - 'defaultFormat' => 'json', - ), - $context->routeSuffix, - $context->apiBasePath - ); - return; - } - $body['path'] = $resolvedPath; + else if ($method === 'PATCH') { + // D1: reject {operation:'delete'} on PATCH mirroring Node files.js + // (1148-1153). File deletion must use DELETE /v1/files/{fileUuid}. + $patchOperation = ''; + if (isset($body['operation']) && is_string($body['operation'])) { + $patchOperation = strtolower(trim($body['operation'])); } + if ($patchOperation === 'delete') { + SiteRouteUtils::sendFormattedResponse( + array( + 'message' => 'Use DELETE /x/api/v1/files/{fileUuid} for file deletion', + ), + array( + 'statusCode' => 400, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + $context->routeSuffix, + $context->apiBasePath + ); + return; + } + } + // D1: always resolve the file path from the UUID path param; stop + // honoring a client-supplied body.path (spec defines fileUuid only, + // not a body path — Node canonical). Previously a client could bypass + // UUID resolution by supplying body.path directly. + if ($fileUuid === '') { + SiteRouteUtils::sendFormattedResponse( + array( + 'message' => 'File uuid is required', + ), + array( + 'statusCode' => 400, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + $context->routeSuffix, + $context->apiBasePath + ); + return; + } + $resolvedPath = haxcmsResolveRequestedFilePathFromUuid($context, $fileUuid); + // D52: reject non-UUID tokens with a 400 error (Node canonical) + if ($resolvedPath === false) { + SiteRouteUtils::sendFormattedResponse( + array( + 'message' => 'File uuid is required and must be a valid UUID', + ), + array( + 'statusCode' => 400, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + $context->routeSuffix, + $context->apiBasePath + ); + return; + } + if ($resolvedPath === '') { + SiteRouteUtils::sendFormattedResponse( + array( + 'message' => 'File not found for fileUuid', + ), + array( + 'statusCode' => 404, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + $context->routeSuffix, + $context->apiBasePath + ); + return; } + $body['path'] = $resolvedPath; $operations->params = $body; $operations->rawParams = $body; $result = $operations->fileOperation(); diff --git a/system/backend/php/lib/siteRoutes/v1/items.php b/system/backend/php/lib/siteRoutes/v1/items.php index dd2c1aa976..17608e4d36 100644 --- a/system/backend/php/lib/siteRoutes/v1/items.php +++ b/system/backend/php/lib/siteRoutes/v1/items.php @@ -50,13 +50,58 @@ return $navigationMap; }; $buildHaxElementSchemaFromHtml = function ($html = '') { - $tags = SiteRouteUtils::extractCustomElementTagsFromHtml($html); + // E2: rich per-element haxElementSchema mirroring Node items.js + // buildHaxElementSchemaFromHtml (260-302). Previously this returned + // empty stubs (tag + empty properties + empty content). Now parses + // the HTML and for each top-level element extracts tag + attributes + // (as properties) + innerHTML (as content), matching Node's shape. + $source = trim((string) $html); + if ($source === '') { + return array(); + } + if (!class_exists('DOMDocument')) { + return array(); + } + $dom = new DOMDocument(); + $previousLibxmlState = libxml_use_internal_errors(true); + // Wrap in a root div so we can walk top-level children reliably; + // prepend the XML encoding declaration so UTF-8 is preserved. + $wrapped = '
' . $source . '
'; + $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previousLibxmlState); + $root = null; + if ($dom->documentElement && strtolower($dom->documentElement->tagName) === 'div') { + $root = $dom->documentElement; + } + if (!$root) { + return array(); + } $schema = array(); - foreach ($tags as $tag => $count) { + foreach ($root->childNodes as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + $tagName = strtolower($node->tagName); + if ($tagName === '') { + continue; + } + $properties = array(); + if ($node->attributes && $node->attributes->length > 0) { + foreach ($node->attributes as $attr) { + $properties[$attr->name] = ($attr->value === null) ? true : $attr->value; + } + } + // innerHTML: concatenate saveHTML of each child node (mirrors + // Node's node.innerHTML) + $innerHTML = ''; + foreach ($node->childNodes as $child) { + $innerHTML .= $dom->saveHTML($child); + } $schema[] = array( - 'tag' => $tag, - 'properties' => array(), - 'content' => '', + 'tag' => $tagName, + 'properties' => $properties, + 'content' => $innerHTML, ); } return $schema; diff --git a/system/backend/php/lib/siteRoutes/v1/itemsMutation.php b/system/backend/php/lib/siteRoutes/v1/itemsMutation.php index 4c4d2d006b..3593ccd380 100644 --- a/system/backend/php/lib/siteRoutes/v1/itemsMutation.php +++ b/system/backend/php/lib/siteRoutes/v1/itemsMutation.php @@ -57,6 +57,26 @@ } } if ($method === 'POST') { + // E11: validate node/items payload presence mirroring Node items.js + // createItem (555-563). Previously PHP delegated with no 400, so an + // empty POST body silently created a blank node. + $hasItemsPayload = isset($body['items']) && is_array($body['items']) && count($body['items']) > 0; + $hasNodePayload = isset($body['node']) && is_array($body['node']); + if (!$hasItemsPayload && !$hasNodePayload) { + SiteRouteUtils::sendFormattedResponse( + array( + 'message' => 'Node payload is required', + ), + array( + 'statusCode' => 400, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + $context->routeSuffix, + $context->apiBasePath + ); + return; + } $operations->params = $body; $operations->rawParams = $body; $result = $operations->createNode(); @@ -91,6 +111,11 @@ if (!isset($body['node']['details']) || !is_array($body['node']['details'])) { $body['node']['details'] = array(); } + // E10: stop honoring legacy nested node.details.* — reset to only the + // operation so client-supplied node.details values are not preserved. + // Align to the spec's top-level shape, matching Node + // nodeDetailOperations.js which reads from the top-level payload only. + $body['node']['details'] = array(); $body['node']['details']['operation'] = $operation; $operationDetailKeys = array( 'parent', @@ -106,6 +131,7 @@ 'published', 'hideInMenu', 'slug', + 'overridePathauto', ); foreach ($operationDetailKeys as $detailKey) { if (array_key_exists($detailKey, $body) && !array_key_exists($detailKey, $body['node']['details'])) { diff --git a/system/backend/php/lib/siteRoutes/v1/revisions.php b/system/backend/php/lib/siteRoutes/v1/revisions.php index 6c1d01bc53..c065b5be6e 100644 --- a/system/backend/php/lib/siteRoutes/v1/revisions.php +++ b/system/backend/php/lib/siteRoutes/v1/revisions.php @@ -18,17 +18,27 @@ if (!is_string($siteToken)) { $siteToken = ''; } + // E9: resolve slug to UUID before delegating (Node canonical). The restore + // path was already fixed; this covers the GET list/single revision paths + // which previously passed the raw idOrSlug and 404'd on slug lookups. + $resolvedItemId = $idOrSlug; + if (isset($context->site) && $idOrSlug !== '') { + $resolvedItem = SiteRouteUtils::findItemByIdOrSlug($context->site, $idOrSlug); + if ($resolvedItem && isset($resolvedItem->id) && is_string($resolvedItem->id) && $resolvedItem->id !== '') { + $resolvedItemId = (string) $resolvedItem->id; + } + } if ($revisionId !== '') { $body = array( 'site' => array('name' => $siteName), - 'node' => array('id' => $idOrSlug), + 'node' => array('id' => $resolvedItemId), 'hash' => $revisionId, 'site_token' => $siteToken, ); } else { $body = array( 'site' => array('name' => $siteName), - 'node' => array('id' => $idOrSlug), + 'node' => array('id' => $resolvedItemId), 'site_token' => $siteToken, ); } diff --git a/system/backend/php/lib/siteRoutes/v1/search.php b/system/backend/php/lib/siteRoutes/v1/search.php index e45e587c46..fbe2cf8940 100644 --- a/system/backend/php/lib/siteRoutes/v1/search.php +++ b/system/backend/php/lib/siteRoutes/v1/search.php @@ -24,11 +24,11 @@ } $query = trim((string) SiteRouteUtils::getQueryValue('q', '')); if ($query == '') { - $sendTopLevelError(400, 'Query parameter \"q\" is required'); + $sendTopLevelError(400, 'Query parameter "q" is required'); return; } if (strlen($query) > 256) { - $sendTopLevelError(400, 'Query parameter \"q\" exceeds 256 characters'); + $sendTopLevelError(400, 'Query parameter "q" exceeds 256 characters'); return; } $normalizeSearchFields = function ($fields = array()) { diff --git a/system/backend/php/lib/siteRoutes/v1/views.php b/system/backend/php/lib/siteRoutes/v1/views.php index bf1fc7b723..4481d86d38 100644 --- a/system/backend/php/lib/siteRoutes/v1/views.php +++ b/system/backend/php/lib/siteRoutes/v1/views.php @@ -153,12 +153,20 @@ } return $records; }; - $resolveViewResults = function ($view) use ($site, $apiBasePath, $applyViewQueryFilters) { + $resolveViewResults = function ($view) use ($site, $apiBasePath, $applyViewQueryFilters, $context) { $viewQuery = (isset($view['query']) && is_array($view['query'])) ? $view['query'] : array(); $source = isset($viewQuery['source']) ? (string) $viewQuery['source'] : 'items'; if ($source === 'tags') { $tagMap = array(); $items = SiteRouteUtils::getOrderedItems($site); + // A4: anon callers must not see tags from unpublished/hidden items + // (mirror Node views.js:155-158). Only the anon-visibility filter + // is applied here, not the full applyItemFilters query filters. + if (SiteRouteUtils::isAnonymousSiteApiRequest($context)) { + $items = array_values(array_filter($items, function ($item) { + return SiteRouteUtils::isItemVisibleToAnonymous($item); + })); + } foreach ($items as $item) { $tags = SiteRouteUtils::normalizeTagList( (isset($item->metadata) && is_object($item->metadata) && isset($item->metadata->tags)) @@ -188,6 +196,14 @@ } $queryLower = strtolower($query); $items = SiteRouteUtils::getOrderedItems($site); + // A4: anon callers must not search unpublished/hidden items + // (mirror Node views.js:183-186). Only the anon-visibility filter + // is applied here, not the full applyItemFilters query filters. + if (SiteRouteUtils::isAnonymousSiteApiRequest($context)) { + $items = array_values(array_filter($items, function ($item) { + return SiteRouteUtils::isItemVisibleToAnonymous($item); + })); + } $records = array(); foreach ($items as $item) { $body = SiteRouteUtils::getItemContent($site, $item); @@ -212,7 +228,9 @@ } $items = SiteRouteUtils::getOrderedItems($site); $items = $applyViewQueryFilters($items, $viewQuery); - $items = SiteRouteUtils::applyItemFilters($items, $site); + // A4: pass the anonymous-visibility context so unpublished/hidden items + // are filtered out for anonymous callers, matching items/search parity. + $items = SiteRouteUtils::applyItemFilters($items, $site, $context); $records = array(); foreach ($items as $item) { $records[] = SiteRouteUtils::itemToSummary($item, $apiBasePath); diff --git a/system/backend/php/lib/systemRoutes/SystemApiRouter.php b/system/backend/php/lib/systemRoutes/SystemApiRouter.php index eb7b077859..4db1447905 100644 --- a/system/backend/php/lib/systemRoutes/SystemApiRouter.php +++ b/system/backend/php/lib/systemRoutes/SystemApiRouter.php @@ -116,6 +116,24 @@ public static function dispatch() ); return true; } + // F2/Q14+F3: enforce the provider-search site token in the router for a + // consistent 403 envelope. The spec will be updated by node-backend to + // declare bearer+siteToken; until the spec sync, this explicit check + // enforces the Q14 decision. siteName validation stays in the handler. + $providerSearchFailure = SystemApiSecurity::enforceProviderSearchSiteToken($routeName, $context->method, $context); + if ($providerSearchFailure !== null) { + SiteRouteUtils::sendFormattedResponse( + array('message' => $providerSearchFailure['message']), + array( + 'statusCode' => 403, + 'allowedFormats' => array('json'), + 'defaultFormat' => 'json', + ), + is_string($context->routeSuffix) ? $context->routeSuffix : '', + $context->apiBasePath + ); + return true; + } if (!isset($match['file']) || !is_string($match['file']) || !file_exists($match['file'])) { SiteRouteUtils::sendFormattedResponse( array('message' => 'System API handler file missing'), diff --git a/system/backend/php/lib/systemRoutes/SystemApiSecurity.php b/system/backend/php/lib/systemRoutes/SystemApiSecurity.php index c04b08666e..c61e0e41c9 100644 --- a/system/backend/php/lib/systemRoutes/SystemApiSecurity.php +++ b/system/backend/php/lib/systemRoutes/SystemApiSecurity.php @@ -1,4 +1,5 @@ getHeader('X-HAXCMS-Site-Token'); + if (is_string($headerValue)) { + $siteToken = $headerValue; + } + } + if ($siteToken === '') { + return array( + 'status' => 403, + 'message' => 'X-HAXCMS-Site-Token header is required for this endpoint', + ); + } + // Resolve siteName from the query param for token validation. If + // siteName is missing, defer to the handler's siteName validation (F3). + $siteName = isset($_GET['siteName']) ? (string) $_GET['siteName'] : ''; + if ($siteName === '') { + return null; + } + $validToken = false; if ( - $normalizedMethod === 'GET' && - in_array($route, $dashboardReadRoutes, true) + isset($GLOBALS['HAXCMS']) && + is_object($GLOBALS['HAXCMS']) && + method_exists($GLOBALS['HAXCMS'], 'validateSiteToken') ) { - return 'authenticated'; + $validToken = $GLOBALS['HAXCMS']->validateSiteToken($siteName, $siteToken); } - $adminRoutes = array( - 'v1/configuration/api-keys', - 'v1/configuration/media', - 'v1/configuration/schema-files/operations', - 'v1/blocks', - 'v1/skeletons', - 'v1/skeletons/:skeletonName', - 'v1/themes', - ); - if (in_array($route, $adminRoutes, true)) { - return 'admin'; + else { + $validToken = SiteRouteUtils::validateSiteToken($siteName, $siteToken); } - return 'authenticated'; + if (!$validToken) { + return array( + 'status' => 403, + 'message' => 'Invalid X-HAXCMS-Site-Token header', + ); + } + return null; } /** * Canonical system READ operations that declare userTokenHeader in the diff --git a/system/backend/php/lib/systemRoutes/SystemRoutesMap.php b/system/backend/php/lib/systemRoutes/SystemRoutesMap.php index 4936cb95e6..fa24810ede 100644 --- a/system/backend/php/lib/systemRoutes/SystemRoutesMap.php +++ b/system/backend/php/lib/systemRoutes/SystemRoutesMap.php @@ -51,8 +51,7 @@ public static function getRoutesMap() 'v1/openapi.json' => dirname(__FILE__) . '/discovery/openapi.php', 'v1/openapi.yaml' => dirname(__FILE__) . '/discovery/openapi.php', 'v1/session' => dirname(__FILE__) . '/v1/session.php', - 'v1/session/login' => dirname(__FILE__) . '/v1/session.php', - 'v1/session/logout' => dirname(__FILE__) . '/v1/session.php', + 'v1/session/refresh' => dirname(__FILE__) . '/v1/session.php', 'v1/session/connection-settings' => dirname(__FILE__) . '/v1/session.php', 'v1/session/connection-test' => dirname(__FILE__) . '/v1/session.php', 'v1/session/user' => dirname(__FILE__) . '/v1/session.php', @@ -89,7 +88,6 @@ public static function getRoutesMap() 'v1/system/version' => dirname(__FILE__) . '/v1/settings.php', 'v1/entities' => dirname(__FILE__) . '/v1/settings.php', 'v1/schemas' => dirname(__FILE__) . '/v1/settings.php', - 'v1/integrations/app-store/providers/:provider/search' => dirname(__FILE__) . '/v1/integrations.php', 'v1/configuration/api-keys' => dirname(__FILE__) . '/v1/settings.php', 'v1/configuration/media' => dirname(__FILE__) . '/v1/settings.php', 'v1/configuration/schema-files/operations' => dirname(__FILE__) . '/v1/settings.php', diff --git a/system/backend/php/lib/systemRoutes/openapi/system-spec.yaml b/system/backend/php/lib/systemRoutes/openapi/system-spec.yaml index 0a3fa3c4f3..cfe4ef2187 100644 --- a/system/backend/php/lib/systemRoutes/openapi/system-spec.yaml +++ b/system/backend/php/lib/systemRoutes/openapi/system-spec.yaml @@ -117,8 +117,11 @@ paths: - session operationId: sessionAuthGet summary: Validate a JWT and return authenticated session details - security: - - bearerAuth: [] + description: > + Anonymous login probe. Returns session details when a valid JWT is + supplied and a minimal unauthenticated response otherwise. No bearer + auth is required to reach this endpoint. + security: [] responses: "200": description: Authenticated session details @@ -135,8 +138,11 @@ paths: - session operationId: sessionAuthPost summary: Validate a JWT and return authenticated session details - security: - - bearerAuth: [] + description: > + Anonymous login probe. Returns session details when a valid JWT is + supplied (body or query) and a minimal unauthenticated response + otherwise. No bearer auth is required to reach this endpoint. + security: [] requestBody: required: false content: @@ -564,10 +570,20 @@ paths: description: > Proxies a search request to a registered app-store provider (e.g. product-card, media, etc.) and returns the provider's response payload. - Moved to the system API in D38; documented here per D57. + Moved to the system API in D38; documented here per D57. Requires a + valid bearer JWT and site token so only authenticated site contexts + can broker upstream provider searches. parameters: - $ref: "#/components/parameters/AppStoreProvider" - security: [] + - name: siteName + in: query + required: true + schema: + type: string + description: Site machine name used to validate site token scope + security: + - bearerAuth: [] + siteTokenHeader: [] responses: "200": description: Provider search response payload @@ -788,19 +804,20 @@ paths: post: tags: - settings - operationId: saveApiKeysPost - summary: Update API key settings + operationId: getApiKeysPost + summary: Return configured API keys and provider statuses (read alias of GET) + description: > + Read-only alias of GET /configuration/api-keys. POST is accepted for + callers that prefer POST over GET but does not write; use PATCH to + update API key settings. Single-user deployment assumption: the + NodeJS backend does not model an admin/superUser tier; these settings + are written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ApiKeysSettings" + userTokenHeader: [] responses: "200": - description: API key update response + description: API key settings content: application/json: schema: @@ -812,6 +829,11 @@ paths: - settings operationId: saveApiKeysPatch summary: Update API key settings + description: > + Write operation — updates API key settings. Single-user deployment + assumption: the NodeJS backend does not model an admin/superUser + tier; settings are written by the single authenticated dashboard + user. security: - bearerAuth: [] userTokenHeader: [] @@ -851,19 +873,20 @@ paths: post: tags: - settings - operationId: saveMediaSettingsPost - summary: Update media and upload configuration + operationId: getMediaSettingsPost + summary: Return media and upload configuration (read alias of GET) + description: > + Read-only alias of GET /configuration/media. POST is accepted for + callers that prefer POST over GET but does not write; use PATCH to + update media settings. Single-user deployment assumption: the + NodeJS backend does not model an admin/superUser tier; these settings + are written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/MediaSettings" + userTokenHeader: [] responses: "200": - description: Media settings update response + description: Media settings content: application/json: schema: @@ -875,6 +898,11 @@ paths: - settings operationId: saveMediaSettingsPatch summary: Update media and upload configuration + description: > + Write operation — updates media and upload configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -899,6 +927,12 @@ paths: - settings operationId: schemaFileOperation summary: Perform schema file operation in system configuration storage + description: > + Write operation — performs schema file operations (rename, delete, + upload) in system configuration storage. Single-user deployment + assumption: the NodeJS backend does not model an admin/superUser + tier; operations are performed by the single authenticated dashboard + user. security: - bearerAuth: [] userTokenHeader: [] @@ -925,6 +959,7 @@ paths: summary: Return available system blocks security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Available block list @@ -937,19 +972,20 @@ paths: post: tags: - settings - operationId: saveEnabledBlocksPost - summary: Update enabled block configuration using the blocks collection endpoint + operationId: systemBlocksPost + summary: Return available system blocks (read alias of GET) + description: > + Read-only alias of GET /blocks. POST is accepted for callers that + prefer POST over GET but does not write; use PATCH to update enabled + block configuration. Single-user deployment assumption: the NodeJS + backend does not model an admin/superUser tier; these settings are + written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EnabledCollectionSettings" + userTokenHeader: [] responses: "200": - description: Enabled collection update response + description: Available block list content: application/json: schema: @@ -961,6 +997,11 @@ paths: - settings operationId: saveEnabledBlocksPatch summary: Update enabled block configuration using the blocks collection endpoint + description: > + Write operation — updates enabled block configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -987,6 +1028,7 @@ paths: summary: Return available system skeletons security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Available skeleton list @@ -1000,27 +1042,23 @@ paths: tags: - settings operationId: systemSkeletonsPost - summary: Upload a skeleton resource or perform skeleton collection operations + summary: Return available system skeletons (read alias of GET) + description: > + Read-only alias of GET /skeletons. POST is accepted for callers that + prefer POST over GET but does not write; use PATCH to update enabled + skeleton configuration. Single-user deployment assumption: the NodeJS + backend does not model an admin/superUser tier; these settings are + written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: false - content: - multipart/form-data: - schema: - $ref: "#/components/schemas/SkeletonUploadRequest" - application/json: - schema: - $ref: "#/components/schemas/SkeletonMutationRequest" + userTokenHeader: [] responses: "200": - description: Skeleton resource operation response + description: Available skeleton list content: application/json: schema: $ref: "#/components/schemas/ApiEnvelope" - "400": - $ref: "#/components/responses/BadRequest" "403": $ref: "#/components/responses/Forbidden" patch: @@ -1029,6 +1067,11 @@ paths: operationId: saveEnabledSkeletonsPatch summary: Update enabled skeleton configuration using the skeletons collection endpoint + description: > + Write operation — updates enabled skeleton configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -1161,6 +1204,7 @@ paths: summary: Return available system themes security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Available theme list @@ -1173,19 +1217,20 @@ paths: post: tags: - settings - operationId: saveEnabledThemesPost - summary: Update enabled theme configuration using the themes collection endpoint + operationId: systemThemesPost + summary: Return available system themes (read alias of GET) + description: > + Read-only alias of GET /themes. POST is accepted for callers that + prefer POST over GET but does not write; use PATCH to update enabled + theme configuration. Single-user deployment assumption: the NodeJS + backend does not model an admin/superUser tier; these settings are + written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EnabledCollectionSettings" + userTokenHeader: [] responses: "200": - description: Enabled collection update response + description: Available theme list content: application/json: schema: @@ -1197,6 +1242,11 @@ paths: - settings operationId: saveEnabledThemesPatch summary: Update enabled theme configuration using the themes collection endpoint + description: > + Write operation — updates enabled theme configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -2448,13 +2498,30 @@ components: type: string LoginRequest: type: object + description: > + Login credentials or JWT revalidation token. When username/password are + supplied the server validates them and issues a fresh JWT. When jwt is + supplied (body or query) the server revalidates the existing token and + returns it if still valid, enabling a revalidate-without-credentials + login flow. properties: + username: + type: string + description: Username (primary field name accepted by v1 handlers) + password: + type: string + description: Password u: type: string - description: Username + description: Legacy username alias (v0 compatibility) p: type: string - description: Password + description: Legacy password alias (v0 compatibility) + jwt: + type: string + description: > + Existing JWT to revalidate. When supplied (body or query) the + server validates the token and returns it if still valid. additionalProperties: true SiteActionRequest: type: object diff --git a/system/backend/php/lib/systemRoutes/v1/session.php b/system/backend/php/lib/systemRoutes/v1/session.php index acc32efe65..8deadcc071 100644 --- a/system/backend/php/lib/systemRoutes/v1/session.php +++ b/system/backend/php/lib/systemRoutes/v1/session.php @@ -121,10 +121,11 @@ function haxcmsValidateSessionIAMAuthorization() $operations->params = $context->body; $operations->rawParams = $context->body; } - unset($operations->params['jwt']); + // D2/Q7: keep jwt in params so the login revalidate branch fires (Node + // supports jwt from body/query). user_token and site_token are still + // stripped — those are header-based, not body params. unset($operations->params['user_token']); unset($operations->params['site_token']); - unset($operations->rawParams['jwt']); unset($operations->rawParams['user_token']); unset($operations->rawParams['site_token']); $route = $context->routeSuffix;