feat: serve cached installer scripts
This commit is contained in:
parent
4dc27cb86f
commit
02ce67049e
5 changed files with 193 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
35
src/Controller/PublicScriptController.php
Normal file
35
src/Controller/PublicScriptController.php
Normal 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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
src/Service/CachePathResolver.php
Normal file
39
src/Service/CachePathResolver.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
46
src/Service/CachedScriptResolver.php
Normal file
46
src/Service/CachedScriptResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
tests/Controller/PublicScriptControllerTest.php
Normal file
69
tests/Controller/PublicScriptControllerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue