1
0
Fork 0

feat: serve cached installer scripts

This commit is contained in:
thibaud-leclere 2026-05-05 09:53:54 +02:00
parent 4dc27cb86f
commit 02ce67049e
5 changed files with 193 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,35 @@
<?php
namespace App\Controller;
use App\Service\CachedScriptResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PublicScriptController extends AbstractController
{
public function __construct(private readonly CachedScriptResolver $resolver)
{
}
#[Route('/{path}', name: 'public_script', requirements: ['path' => '(?!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)),
]);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Service;
use RuntimeException;
final class CachePathResolver
{
public function __construct(
private readonly string $cacheDir,
private readonly string $projectDir,
) {
}
public function servedScriptPath(string $cacheKey): string
{
$cacheKey = ltrim(str_replace('\\', '/', $cacheKey), '/');
if ($cacheKey === '' || str_contains($cacheKey, '../') || str_contains($cacheKey, '/..')) {
throw new RuntimeException('Invalid script cache key.');
}
return $this->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, '/');
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Service;
use App\Repository\ScriptMappingRepository;
use InvalidArgumentException;
use SplFileInfo;
final class CachedScriptResolver
{
public function __construct(
private readonly PathNormalizer $pathNormalizer,
private readonly ScriptMappingRepository $mappingRepository,
private readonly CachePathResolver $cachePathResolver,
) {
}
public function resolve(string $requestPath): ?SplFileInfo
{
try {
$publicPath = $this->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);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Tests\Controller;
use App\Entity\ScriptMapping;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
final class PublicScriptControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::ensureKernelShutdown();
$this->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());
}
}