diff --git a/src/classes/Api/Api.php b/src/classes/Api/Api.php index 8a9078c..6a207a2 100755 --- a/src/classes/Api/Api.php +++ b/src/classes/Api/Api.php @@ -2,9 +2,9 @@ namespace JohannSchopplich\Headless\Api; -use Exception; use Kirby\Cms\App; use Kirby\Cms\File; +use Kirby\Exception\Exception; use Kirby\Http\Response; use Kirby\Toolkit\A; @@ -15,7 +15,9 @@ class Api */ public static function createHandler(callable ...$fns) { - $context = []; + $context = [ + 'kirby' => App::instance() + ]; return function (...$args) use ($fns, $context) { foreach ($fns as $fn) { @@ -34,6 +36,8 @@ public static function createHandler(callable ...$fns) /** * Create an API response + * + * @remarks * Enforces consistent JSON responses by wrapping Kirby's `Response` class */ public static function createResponse(int $code, $data = null): Response @@ -57,7 +61,7 @@ public static function createResponse(int $code, $data = null): Response /** * Get the status message for the given code * - * @throws \Exception + * @throws \Kirby\Exception\Exception */ private static function getStatusMessage(int $code): string { @@ -90,12 +94,12 @@ public static function createPreflightResponse(): Response $kirby = App::instance(); return new Response('', null, 204, [ + // 204 responses **must not** have a `Content-Length` header + // (https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2) 'Access-Control-Allow-Origin' => $kirby->option('headless.cors.allowOrigin', '*'), 'Access-Control-Allow-Methods' => $kirby->option('headless.cors.allowMethods', 'GET, POST, OPTIONS'), 'Access-Control-Allow-Headers' => $kirby->option('headless.cors.allowHeaders', 'Accept, Content-Type, Authorization, X-Language'), 'Access-Control-Max-Age' => $kirby->option('headless.cors.maxAge', '86400'), - // 204 responses **must not** have a `Content-Length` header - // (https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2) ]); } } diff --git a/src/classes/Api/Middlewares.php b/src/classes/Api/Middlewares.php index f14f36f..9310801 100755 --- a/src/classes/Api/Middlewares.php +++ b/src/classes/Api/Middlewares.php @@ -6,7 +6,6 @@ use Kirby\Exception\NotFoundException; use Kirby\Filesystem\F; use Kirby\Http\Response; -use Kirby\Http\Uri; use Kirby\Panel\Panel; use Kirby\Toolkit\Str; @@ -46,46 +45,7 @@ public static function tryResolveFiles(array $context, array $args) } /** - * Try to resolve global site data - */ - public static function tryResolveSite(array $context, array $args) - { - $kirby = App::instance(); - - // The `$args` array contains the route parameters - if ($kirby->multilang()) { - [$languageCode, $path] = $args; - } else { - [$path] = $args; - } - - if ($path !== '_site') { - return; - } - - $data = $kirby->cache('pages')->getOrSet( - '_site.headless.json', - function () use ($kirby) { - $template = $kirby->template('_site'); - - if (!$template->exists()) { - throw new NotFoundException([ - 'key' => 'template.default.notFound' - ]); - } - - return $template->render([ - 'kirby' => $kirby, - 'site' => $kirby->site() - ]); - } - ); - - return Response::json($data); - } - - /** - * Try to resolve the page id + * Try to resolve the page ID */ public static function tryResolvePage(array $context, array $args) { @@ -118,7 +78,7 @@ public static function tryResolvePage(array $context, array $args) $data = $cache->get($cacheKey); } - // Fetch the page regularly + // Fetch the page data if ($data === null) { $template = $page->template(); @@ -139,93 +99,7 @@ public static function tryResolvePage(array $context, array $args) } /** - * Try to resolve the sitemap tree - * - * @return \Kirby\Http\Response|void - */ - public static function tryResolveSitemap(array $context, array $args) - { - $kirby = App::instance(); - - // The `$args` array contains the route parameters - if ($kirby->multilang()) { - [$languageCode, $path] = $args; - } else { - [$path] = $args; - } - - if ($path !== '_sitemap') { - return; - } - - $sitemap = []; - $cache = $kirby->cache('pages'); - $cacheKey = '_sitemap.headless.json'; - $sitemap = $cache->get($cacheKey); - $withoutBase = fn (string $url) => '/' . (new Uri($url))->path(); - - if ($sitemap === null) { - $isIndexable = option('headless.sitemap.isIndexable'); - $excludeTemplates = option('headless.sitemap.exclude.templates', []); - $excludePages = option('headless.sitemap.exclude.pages', []); - - if (is_callable($excludePages)) { - $excludePages = $excludePages(); - } - - foreach ($kirby->site()->index() as $item) { - /** @var \Kirby\Cms\Page $item */ - if (in_array($item->intendedTemplate()->name(), $excludeTemplates, true)) { - continue; - } - - if (preg_match('!^(?:' . implode('|', $excludePages) . ')$!i', $item->id())) { - continue; - } - - $options = $item->blueprint()->options(); - if (isset($options['sitemap']) && $options['sitemap'] === false) { - continue; - } - - if (is_callable($isIndexable) && $isIndexable($item) === false) { - continue; - } - - $url = [ - 'url' => $withoutBase($item->url()), - 'modified' => $item->modified('Y-m-d', 'date') - ]; - - if ($kirby->multilang()) { - $url['links'] = $kirby->languages()->map(fn ($lang) => [ - // Support ISO 3166-1 Alpha 2 and ISO 639-1 - 'lang' => Str::slug(preg_replace( - '/\.utf-?8$/i', - '', - $lang->locale(LC_ALL) ?? $lang->code() - )), - 'url' => $withoutBase($item->url($lang->code())) - ])->values(); - - $url['links'][] = [ - 'lang' => 'x-default', - 'url' => $withoutBase($item->url()) - ]; - } - - $sitemap[] = $url; - } - - $cache?->set($cacheKey, $sitemap); - } - - return Response::json($sitemap); - } - - /** - * Checks if a bearer token was sent with the request and - * if it matches the one configured in `.env` + * Validates the bearer token sent with the request */ public static function hasBearerToken() { @@ -254,12 +128,11 @@ public static function hasBody(array $context) if (empty($request->body()->data())) { return Api::createResponse(400, [ - 'error' => 'No body was sent with the request' + 'error' => 'Missing request body' ]); } $context['body'] = $request->body(); - $context['query'] = $request->query(); return $context; } diff --git a/src/extensions/api.php b/src/extensions/api.php index 05dda79..0f47187 100644 --- a/src/extensions/api.php +++ b/src/extensions/api.php @@ -2,10 +2,28 @@ use JohannSchopplich\Headless\Api\Api; use Kirby\Data\Json; +use Kirby\Exception\NotFoundException; +use Kirby\Http\Uri; use Kirby\Kql\Kql; +use Kirby\Toolkit\Str; + +$validateOptionalBearerToken = function (array $context, array $args) { + /** @var \Kirby\Cms\App */ + $kirby = $context['kirby']; + + $token = $kirby->option('headless.token'); + $authorization = $kirby->request()->header('Authorization'); + + if ( + !empty($token) && + (empty($authorization) || $authorization !== 'Bearer ' . $token) + ) { + return Api::createResponse(401); + } +}; return [ - 'routes' => function (\Kirby\Cms\App $kirby) { + 'routes' => function (\Kirby\Cms\App $kirby) use ($validateOptionalBearerToken) { $authMethod = $kirby->option('kql.auth', true); $auth = $authMethod !== false && $authMethod !== 'bearer'; @@ -21,8 +39,7 @@ ], /** - * Allow KQL to be used with bearer token authentication and - * cache query results + * KQL with bearer token authentication and caching */ [ 'pattern' => 'kql', @@ -35,8 +52,8 @@ function (array $context, array $args) use ($kirby, $authMethod) { return; } - $authorization = $kirby->request()->header('Authorization'); $token = $kirby->option('headless.token'); + $authorization = $kirby->request()->header('Authorization'); if ($authorization !== 'Bearer ' . $token) { return Api::createResponse(401); @@ -72,6 +89,124 @@ function (array $context, array $args) use ($kirby) { return Api::createResponse(200, $data); } ) + ], + + /** + * Generate a sitemap for headless usage + */ + [ + 'pattern' => '__sitemap__', + 'method' => 'GET', + 'auth' => false, + 'action' => Api::createHandler( + $validateOptionalBearerToken, + function (array $context, array $args) use ($kirby) { + $sitemap = []; + $cache = $kirby->cache('pages'); + $cacheKey = '_sitemap.headless.json'; + $sitemap = $cache->get($cacheKey); + $withoutBase = fn (string $url) => '/' . (new Uri($url))->path(); + + if ($sitemap === null) { + $isIndexable = option('headless.sitemap.isIndexable'); + $excludeTemplates = option('headless.sitemap.exclude.templates', []); + $excludePages = option('headless.sitemap.exclude.pages', []); + + if (is_callable($excludePages)) { + $excludePages = $excludePages(); + } + + foreach ($kirby->site()->index() as $item) { + /** @var \Kirby\Cms\Page $item */ + if (in_array($item->intendedTemplate()->name(), $excludeTemplates, true)) { + continue; + } + + if (preg_match('!^(?:' . implode('|', $excludePages) . ')$!i', $item->id())) { + continue; + } + + $options = $item->blueprint()->options(); + if (isset($options['sitemap']) && $options['sitemap'] === false) { + continue; + } + + if (is_callable($isIndexable) && $isIndexable($item) === false) { + continue; + } + + $url = [ + 'url' => $withoutBase($item->url()), + 'modified' => $item->modified('Y-m-d', 'date') + ]; + + if ($kirby->multilang()) { + $url['links'] = $kirby->languages()->map(fn ($lang) => [ + // Support ISO 3166-1 Alpha 2 and ISO 639-1 + 'lang' => Str::slug(preg_replace( + '/\.utf-?8$/i', + '', + $lang->locale(LC_ALL) ?? $lang->code() + )), + 'url' => $withoutBase($item->url($lang->code())) + ])->values(); + + $url['links'][] = [ + 'lang' => 'x-default', + 'url' => $withoutBase($item->url()) + ]; + } + + $sitemap[] = $url; + } + + $cache?->set($cacheKey, $sitemap); + } + + return Api::createResponse(201, $sitemap); + } + ) + ], + + /** + * Render a page template as JSON + */ + [ + 'pattern' => '__template__/(:any)', + 'method' => 'GET|POST', + 'auth' => false, + 'action' => Api::createHandler( + $validateOptionalBearerToken, + function (array $context, array $args) use ($kirby) { + $templateName = $args[0] ?? null; + + if (!$templateName) { + throw new NotFoundException([ + 'key' => 'template.default.notFound' + ]); + } + + $data = $kirby->cache('pages')->getOrSet( + $templateName . '.headless.json', + function () use ($args, $kirby) { + $template = $kirby->template($args[0]); + + if (!$template->exists()) { + throw new NotFoundException([ + 'key' => 'template.default.notFound' + ]); + } + + return $template->render([ + 'kirby' => $kirby, + 'site' => $kirby->site() + ]); + } + ); + + return Api::createResponse(201, $data); + } + ) ] ]; } diff --git a/src/extensions/pageMethods.php b/src/extensions/pageMethods.php index d1f2811..27d1c54 100644 --- a/src/extensions/pageMethods.php +++ b/src/extensions/pageMethods.php @@ -24,12 +24,11 @@ 'i18nMeta' => function () { /** @var \Kirby\Cms\Page $this */ $locales = $this->kirby()->languages()->codes(); - $meta = []; foreach ($locales as $locale) { $meta[$locale] = [ - 'title' => $this->content($locale)->title()->value(), + 'title' => $this->content($locale)->get('title')->value(), 'uri' => $this->uri($locale) ]; } diff --git a/src/extensions/routes.php b/src/extensions/routes.php index a24a9be..08b9e5c 100755 --- a/src/extensions/routes.php +++ b/src/extensions/routes.php @@ -5,7 +5,7 @@ return [ /** - * Allow preflight requests, mainly for `fetch` + * Allow preflight requests, mainly for `fetch` requests */ [ 'pattern' => '(:all)', @@ -15,7 +15,7 @@ ], /** - * Return JSON-encoded page data for each request + * Return JSON-encoded page data for every route */ [ 'pattern' => '(:all)', @@ -23,8 +23,6 @@ 'action' => Api::createHandler( [Middlewares::class, 'tryResolveFiles'], [Middlewares::class, 'hasBearerToken'], - [Middlewares::class, 'tryResolveSite'], - [Middlewares::class, 'tryResolveSitemap'], [Middlewares::class, 'tryResolvePage'] ) ]