diff --git a/app/helpers/DemoProxy.php b/app/helpers/DemoProxy.php
new file mode 100644
index 000000000..86ae6d3b9
--- /dev/null
+++ b/app/helpers/DemoProxy.php
@@ -0,0 +1,111 @@
+ $proxiedResponse['body'],
+ 'statusCode' => $statusCode,
+ ];
+ }
+
+ /**
+ * Builds a validated demo API URL for proxying.
+ *
+ * @param string $path Relative request path.
+ * @param array $queryParams Query parameters to append to the URL.
+ * @return string The validated absolute demo API URL.
+ * @throws \InvalidArgumentException If the path is not index.php.
+ * @throws \InvalidArgumentException If the module query parameter is not API.
+ */
+ public static function buildValidatedApiUrl(string $path, array $queryParams): string
+ {
+ $normalizedPath = trim($path, '/');
+ if ($normalizedPath !== 'index.php') {
+ throw new \InvalidArgumentException('Path must be index.php');
+ }
+
+ if (($queryParams['module'] ?? null) !== 'API') {
+ throw new \InvalidArgumentException('Module must be API');
+ }
+
+ $targetUrl = rtrim(self::MATOMO_SWAGGER_PROXY_TARGET, '/') . '/' . $normalizedPath;
+ $query = http_build_query($queryParams);
+
+ if ($query !== '') {
+ $targetUrl .= '?' . $query;
+ }
+
+ return $targetUrl;
+ }
+
+ private static function createContext()
+ {
+ return stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => self::buildHeaders(),
+ 'ignore_errors' => true,
+ 'timeout' => 30,
+ ],
+ ]);
+ }
+
+ private static function buildHeaders(): string
+ {
+ return 'Authorization: ' . self::DEMO_AUTHORIZATION_HEADER;
+ }
+
+ /**
+ * @return array{body: string, responseHeaders: array}
+ */
+ private static function fetchResponse(string $url, $context): array
+ {
+ $body = @file_get_contents($url, false, $context);
+ if ($body === false) {
+ throw new \RuntimeException('Could not proxy HTTP request');
+ }
+
+ return [
+ 'body' => $body,
+ 'responseHeaders' => $http_response_header ?? [],
+ ];
+ }
+
+ private static function parseStatusCode(array $responseHeaders): int
+ {
+ $statusLine = $responseHeaders[0] ?? '';
+
+ if (preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches)) {
+ return (int) $matches[1];
+ }
+
+ return 200;
+ }
+}
diff --git a/app/routes/page.php b/app/routes/page.php
index e1c8903ce..02043e0b2 100644
--- a/app/routes/page.php
+++ b/app/routes/page.php
@@ -20,6 +20,7 @@
use helpers\Content\Category\TracTicketArchiveCategory;
use helpers\Content\Guide;
use helpers\Content\PhpDoc;
+use helpers\DemoProxy;
use helpers\DocumentNotExistException;
use helpers\Environment;
use helpers\OpenApiSpecRegistry;
@@ -31,6 +32,7 @@
use Slim\Exception\HttpNotFoundException;
+
function renderGuide(Slim\Views\Twig $view, Response $response, Psr\Http\Message\UriInterface $uri, Guide $guide, Category $category, string $template = 'guide.twig')
{
return $view->render($response, $template, [
@@ -265,6 +267,27 @@ function renderGuide(Slim\Views\Twig $view, Response $response, Psr\Http\Message
->withStatus(200);
});
+$app->get('/demo/{path:.*}', function (Request $request, Response $response, $args) {
+ $path = $args['path'] ?? '';
+
+ try {
+ $targetUrl = DemoProxy::buildValidatedApiUrl($path, $request->getQueryParams());
+ } catch (\InvalidArgumentException $e) {
+ $response->getBody()->write($e->getMessage());
+ return $response->withStatus(400);
+ }
+
+ try {
+ $proxiedResponse = DemoProxy::get($targetUrl);
+ } catch (\RuntimeException $e) {
+ $response->getBody()->write('Could not proxy demo request');
+ return $response->withStatus(502);
+ }
+
+ $response->getBody()->write($proxiedResponse['body']);
+ return $response->withStatus($proxiedResponse['statusCode']);
+});
+
$app->post('/receive-commit-hook', function (Request $request, Response $response, $args) {
$params = $request->getQueryParams();
if (empty($params["token"]) || !password_verify($params["token"], WEBHOOK_TOKEN)) {
diff --git a/app/templates/api-swagger.twig b/app/templates/api-swagger.twig
index de5fc5512..bfa5954d5 100644
--- a/app/templates/api-swagger.twig
+++ b/app/templates/api-swagger.twig
@@ -19,7 +19,8 @@