diff --git a/config/services.yaml b/config/services.yaml index 79b8ce2..3515cab 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,12 +7,16 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + app.cache_dir: '%env(APP_CACHE_DIR)%' services: # default configuration for services in *this* file _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + string $cacheDir: '%app.cache_dir%' + string $projectDir: '%kernel.project_dir%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/src/Controller/PublicScriptController.php b/src/Controller/PublicScriptController.php new file mode 100644 index 0000000..5b3974c --- /dev/null +++ b/src/Controller/PublicScriptController.php @@ -0,0 +1,35 @@ + '(?!admin(?:/|$)).+'], priority: -255)] + public function __invoke(string $path): Response + { + $file = $this->resolver->resolve($path); + + if ($file === null) { + throw $this->createNotFoundException(); + } + + $content = file_get_contents($file->getPathname()); + if ($content === false) { + return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return new Response($content, Response::HTTP_OK, [ + 'Content-Type' => 'text/x-shellscript; charset=UTF-8', + 'Content-Disposition' => sprintf('inline; filename="%s"', basename($path)), + ]); + } +} diff --git a/src/Service/CachePathResolver.php b/src/Service/CachePathResolver.php new file mode 100644 index 0000000..0573d39 --- /dev/null +++ b/src/Service/CachePathResolver.php @@ -0,0 +1,39 @@ +baseDir().'/'.$cacheKey; + } + + public function scriptCacheKeyForMapping(int $mappingId): string + { + return sprintf('scripts/%d.sh', $mappingId); + } + + public function baseDir(): string + { + if (str_starts_with($this->cacheDir, '/')) { + return rtrim($this->cacheDir, '/'); + } + + return rtrim($this->projectDir, '/').'/'.trim($this->cacheDir, '/'); + } +} diff --git a/src/Service/CachedScriptResolver.php b/src/Service/CachedScriptResolver.php new file mode 100644 index 0000000..a29ee8e --- /dev/null +++ b/src/Service/CachedScriptResolver.php @@ -0,0 +1,46 @@ +pathNormalizer->publicPath($requestPath); + } catch (InvalidArgumentException) { + return null; + } + + $mapping = $this->mappingRepository->findActiveByPublicPath($publicPath); + if ($mapping === null || $mapping->getCacheKey() === null) { + return null; + } + + $path = $this->cachePathResolver->servedScriptPath($mapping->getCacheKey()); + $baseDir = $this->cachePathResolver->baseDir(); + $realPath = realpath($path); + $realBase = realpath($baseDir); + + if ($realPath === false || $realBase === false || !str_starts_with($realPath, rtrim($realBase, '/').'/')) { + return null; + } + + if (!is_file($realPath) || !is_readable($realPath)) { + return null; + } + + return new SplFileInfo($realPath); + } +} diff --git a/tests/Controller/PublicScriptControllerTest.php b/tests/Controller/PublicScriptControllerTest.php new file mode 100644 index 0000000..ace0fd8 --- /dev/null +++ b/tests/Controller/PublicScriptControllerTest.php @@ -0,0 +1,69 @@ +client = self::createClient(); + + $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->entityManager->close(); + } + + public function testUnknownPathReturnsNotFound(): void + { + $this->client->request('GET', '/missing/install.sh'); + + self::assertResponseStatusCodeSame(404); + } + + public function testCachedScriptIsServed(): void + { + $container = static::getContainer(); + $cacheDir = $container->getParameter('app.cache_dir'); + if (!str_starts_with($cacheDir, '/')) { + $cacheDir = $container->getParameter('kernel.project_dir').'/'.$cacheDir; + } + @mkdir($cacheDir.'/scripts', 0775, true); + file_put_contents($cacheDir.'/scripts/test.sh', "#!/bin/sh\necho ok\n"); + + $mapping = (new ScriptMapping()) + ->setPublicPath('mcp/graylog/install.sh') + ->setRepositoryUrl('https://forge.lclr.dev/AI/graylog-mcp.git') + ->setGitRef('main') + ->setRepositoryFilePath('install.sh') + ->setCacheKey('scripts/test.sh'); + $mapping->markSyncSucceeded('scripts/test.sh'); + + $this->entityManager->persist($mapping); + $this->entityManager->flush(); + + $this->client->request('GET', '/mcp/graylog/install.sh'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'text/x-shellscript; charset=UTF-8'); + self::assertStringContainsString('echo ok', (string) $this->client->getResponse()->getContent()); + } +}