1
0
Fork 0

Compare commits

...

10 commits

Author SHA1 Message Date
thibaud-leclere
5b86d89be2 build: add mappings sync cron 2026-05-05 11:23:28 +02:00
thibaud-leclere
e53b4ca85a feat: add script mappings sync command 2026-05-05 11:14:04 +02:00
thibaud-leclere
e3517c3fcd style: add utility admin interface 2026-05-05 10:57:14 +02:00
thibaud-leclere
c409b957c2 feat: configure root redirect 2026-05-05 10:28:06 +02:00
thibaud-leclere
48def71a28 docs: document deployment and usage 2026-05-05 10:07:35 +02:00
thibaud-leclere
800f4c233f test: centralize web database setup 2026-05-05 10:06:55 +02:00
thibaud-leclere
65e59e740c feat: synchronize installer scripts from git 2026-05-05 10:05:48 +02:00
thibaud-leclere
4e2f181dd9 feat: manage script mappings from admin 2026-05-05 10:00:56 +02:00
thibaud-leclere
c39a24a4f2 feat: add admin authentication 2026-05-05 09:56:49 +02:00
thibaud-leclere
02ce67049e feat: serve cached installer scripts 2026-05-05 09:53:54 +02:00
44 changed files with 2229 additions and 10 deletions

2
.gitignore vendored
View file

@ -7,6 +7,8 @@
/public/bundles/
/var/
/vendor/
/.superpowers/
/.playwright-mcp/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# Get Installer Bootstrap
Application web qui sert des scripts `.sh` depuis un cache local alimenté par des dépôts Git.
Exemple : un mapping `mcp/graylog/install.sh` peut servir le fichier `install.sh` du dépôt `https://forge.lclr.dev/AI/graylog-mcp.git`.
## Fonctionnement
- `/admin` affiche lécran de connexion puis linterface de gestion.
- `/` redirige vers lURL configurée dans `/admin/settings`, ou vers `/admin` si aucune URL nest configurée.
- Un mapping lie un chemin public `.sh` à une URL Git, une référence Git et un chemin de fichier dans le dépôt.
- Le bouton `Synchroniser` clone ou met à jour le dépôt, extrait la référence demandée et copie le fichier dans le cache.
- En production, un cron embarqué dans limage relance `app:mappings:sync` toutes les 5 minutes.
- Les chemins publics hors `/admin` servent uniquement les scripts déjà synchronisés.
- Un token daccès optionnel peut être renseigné pour les dépôts privés en HTTPS. Il est utilisé via `GIT_ASKPASS` pendant la synchronisation.
## Développement
```bash
docker compose -f compose.yaml -f compose.dev.yaml up -d --build
docker compose -f compose.yaml -f compose.dev.yaml exec app php bin/console app:admin:create admin 'change-me'
```
Lapplication est disponible sur `http://localhost:8080`.
Le fichier `compose.dev.yaml` monte le répertoire local dans `/app`, ce qui permet de tester les changements sans rebuild de limage.
## Production / Coolify
Déployer avec `compose.yaml`.
Variables utiles :
```dotenv
APP_SECRET=replace-with-a-long-random-secret
HTTP_PORT=8080
```
Les migrations SQLite sont exécutées au démarrage du conteneur `app`. Les volumes Docker conservent :
- `app-data` : base SQLite `/app/var/data/app.db`
- `app-cache` : dépôts clonés et scripts servis depuis `/app/var/bootstrap-cache`
Le fichier `crontab` à la racine du projet est copié dans limage `app`. Le cron démarre uniquement quand `APP_ENV=prod` et que le conteneur lance `php-fpm`.
Créer ou mettre à jour un compte admin :
```bash
docker compose exec app php bin/console app:admin:create admin 'strong-password'
```
## Tests
```bash
docker compose -f compose.yaml -f compose.dev.yaml run --rm -e APP_ENV=test app php vendor/bin/phpunit
```
## Servir un script
1. Se connecter sur `/admin`.
2. Optionnel : configurer lURL de redirection de `/` via `Configuration`.
3. Créer un mapping :
- Chemin public : `mcp/graylog/install.sh`
- Dépôt : `https://forge.lclr.dev/AI/graylog-mcp.git`
- Réf. : `main`
- Fichier : `install.sh`
- Token : optionnel, pour dépôt privé
4. Cliquer sur `Synchroniser`.
5. Appeler `http://localhost:8080/mcp/graylog/install.sh`.

View file

@ -5,7 +5,10 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\User
property: username
firewalls:
dev:
@ -14,17 +17,16 @@ security:
security: false
main:
lazy: true
provider: users_in_memory
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
provider: app_user_provider
custom_authenticator: App\Security\LoginFormAuthenticator
logout:
path: admin_logout
target: admin_login
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:

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

4
crontab Normal file
View file

@ -0,0 +1,4 @@
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/5 * * * * . /app/var/cron-env && su -m www-data -s /bin/sh -c 'cd /app && php bin/console app:mappings:sync' >> /proc/1/fd/1 2>> /proc/1/fd/2

View file

@ -14,8 +14,10 @@ RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-
COPY . .
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint
COPY crontab /etc/crontabs/root
RUN chmod +x /usr/local/bin/app-entrypoint \
&& chmod 0600 /etc/crontabs/root \
&& mkdir -p var/data var/bootstrap-cache \
&& composer dump-autoload --classmap-authoritative --no-dev \
&& composer run-script --no-dev post-install-cmd \

View file

@ -5,7 +5,24 @@ mkdir -p /app/var/data /app/var/bootstrap-cache
chown -R www-data:www-data /app/var
if [ "${APP_ENV:-prod}" != "test" ]; then
php /app/bin/console cache:clear --no-warmup --no-interaction
php /app/bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
fi
if [ "${APP_ENV:-prod}" = "prod" ] && [ "${1:-}" = "php-fpm" ]; then
php -r '
$names = ["APP_ENV", "APP_DEBUG", "APP_SECRET", "DATABASE_URL", "APP_CACHE_DIR", "DEFAULT_URI"];
foreach ($names as $name) {
$value = getenv($name);
if ($value !== false) {
echo "export ".$name."=".escapeshellarg($value).PHP_EOL;
}
}
' > /app/var/cron-env
chown www-data:www-data /app/var/cron-env
chmod 0600 /app/var/cron-env
crond -l 8 -L /proc/1/fd/1
fi
exec "$@"

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505081600 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create application settings.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE app_setting (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(190) NOT NULL, value CLOB DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)');
$this->addSql('CREATE UNIQUE INDEX uniq_app_setting_name ON app_setting (name)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE app_setting');
}
}

322
public/admin.css Normal file
View file

@ -0,0 +1,322 @@
:root {
color-scheme: light;
--admin-bg: #f6f7f9;
--admin-panel: #ffffff;
--admin-panel-soft: #eef1f5;
--admin-text: #172033;
--admin-muted: #647083;
--admin-border: #dfe4ec;
--admin-border-soft: #edf0f4;
--admin-primary: #172033;
--admin-primary-hover: #26344d;
--admin-danger: #b42318;
--admin-danger-soft: #fff1f0;
--admin-success: #0d7a4f;
--admin-success-soft: #ecfdf3;
}
* {
box-sizing: border-box;
}
body.admin-page,
body.admin-auth {
margin: 0;
min-height: 100vh;
background: var(--admin-bg);
color: var(--admin-text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
}
a {
color: inherit;
}
.admin-shell {
width: min(1440px, calc(100% - 48px));
margin: 0 auto;
padding: 32px 0;
}
.auth-shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
}
.admin-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.admin-toolbar h1,
.auth-card h1 {
margin: 0;
color: var(--admin-text);
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.eyebrow {
margin: 0 0 4px;
color: var(--admin-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.toolbar-actions,
.row-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.row-actions form {
margin: 0;
}
.button {
display: inline-flex;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 11px;
border: 1px solid transparent;
border-radius: 6px;
font: inherit;
font-weight: 650;
line-height: 1.2;
text-decoration: none;
cursor: pointer;
}
.button-primary {
background: var(--admin-primary);
color: #ffffff;
}
.button-primary:hover {
background: var(--admin-primary-hover);
}
.button-secondary,
.button-ghost {
border-color: var(--admin-border);
background: #ffffff;
color: var(--admin-text);
}
.button-secondary:hover,
.button-ghost:hover {
background: var(--admin-panel-soft);
}
.button-danger {
border-color: #f3b4ae;
background: var(--admin-danger-soft);
color: var(--admin-danger);
}
.button-danger:hover {
background: #ffe3e0;
}
.table-panel,
.admin-card {
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-panel);
box-shadow: 0 16px 40px rgba(23, 32, 51, 0.06);
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th {
background: var(--admin-panel-soft);
color: var(--admin-muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
text-align: left;
text-transform: uppercase;
}
.admin-table th,
.admin-table td {
padding: 11px 12px;
border-bottom: 1px solid var(--admin-border-soft);
vertical-align: top;
}
.admin-table tbody tr:last-child td {
border-bottom: 0;
}
.admin-table code {
color: #243149;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.text-muted {
color: var(--admin-muted);
}
.error-cell {
max-width: 260px;
color: var(--admin-danger);
word-break: break-word;
}
.status-badge {
display: inline-flex;
min-width: 42px;
justify-content: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-success {
background: var(--admin-success-soft);
color: var(--admin-success);
}
.status-muted {
background: var(--admin-panel-soft);
color: var(--admin-muted);
}
.empty-state {
color: var(--admin-muted);
text-align: center;
}
.alert {
margin: 0 0 14px;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 6px;
background: #ffffff;
font-weight: 600;
}
.alert-success {
border-color: #b7e4ce;
background: var(--admin-success-soft);
color: var(--admin-success);
}
.alert-error {
border-color: #f3b4ae;
background: var(--admin-danger-soft);
color: var(--admin-danger);
}
.auth-card {
width: min(100%, 380px);
padding: 24px;
}
.admin-form-shell {
width: min(760px, calc(100% - 48px));
}
.admin-form {
display: grid;
gap: 14px;
}
.admin-form > div {
display: grid;
gap: 6px;
}
.admin-form label {
color: var(--admin-text);
font-weight: 700;
}
.admin-form input[type="text"],
.admin-form input[type="password"],
.admin-form input[type="url"] {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--admin-border);
border-radius: 6px;
background: #ffffff;
color: var(--admin-text);
font: inherit;
}
.admin-form input[type="text"]:focus,
.admin-form input[type="password"]:focus,
.admin-form input[type="url"]:focus {
border-color: var(--admin-primary);
outline: 3px solid rgba(23, 32, 51, 0.12);
}
.admin-form input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--admin-primary);
}
.admin-form .help-text,
.admin-form .form-text,
.admin-form small {
color: var(--admin-muted);
font-size: 12px;
}
.admin-form ul {
margin: 0;
padding-left: 18px;
color: var(--admin-danger);
}
@media (max-width: 900px) {
.admin-shell,
.admin-form-shell {
width: min(100% - 24px, 760px);
padding: 18px 0;
}
.admin-toolbar {
align-items: flex-start;
flex-direction: column;
}
.toolbar-actions {
width: 100%;
}
.toolbar-actions .button {
flex: 1 1 auto;
}
.table-panel {
overflow-x: auto;
}
.admin-table {
min-width: 980px;
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:admin:create', description: 'Create or update an admin user.')]
final class CreateAdminUserCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED)
->addArgument('password', InputArgument::REQUIRED);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$username = (string) $input->getArgument('username');
$password = (string) $input->getArgument('password');
$user = $this->userRepository->findOneBy(['username' => $username]) ?? (new User())->setUsername($username);
$user->setPasswordHash($this->passwordHasher->hashPassword($user, $password));
$this->entityManager->persist($user);
$this->entityManager->flush();
$output->writeln(sprintf('Admin user "%s" is ready.', $username));
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Command;
use App\Exception\GitSyncFailedException;
use App\Repository\ScriptMappingRepository;
use App\Service\GitSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:mappings:sync', description: 'Synchronize all script mappings.')]
final class SyncScriptMappingsCommand extends Command
{
public function __construct(
private readonly ScriptMappingRepository $scriptMappingRepository,
private readonly GitSynchronizer $gitSynchronizer,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$synchronized = 0;
$failed = 0;
foreach ($this->scriptMappingRepository->findAllOrderedByPublicPath() as $mapping) {
try {
$this->gitSynchronizer->sync($mapping);
++$synchronized;
} catch (GitSyncFailedException) {
++$failed;
$io->error(sprintf('Mapping "%s" synchronization failed.', $mapping->getPublicPath()));
} finally {
$this->entityManager->flush();
}
}
$summary = sprintf('%d mapping(s) synchronized, %d failed.', $synchronized, $failed);
if ($failed === 0) {
$io->success($summary);
return Command::SUCCESS;
}
$io->warning($summary);
return Command::FAILURE;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Controller\Admin;
use App\Form\LoginFormType;
use LogicException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class AuthController extends AbstractController
{
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser() !== null) {
return $this->redirectToRoute('admin_dashboard');
}
$form = $this->createForm(LoginFormType::class, [
'username' => $authenticationUtils->getLastUsername(),
]);
return $this->render('admin/login.html.twig', [
'loginForm' => $form,
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route('/admin/logout', name: 'admin_logout', methods: ['GET'])]
public function logout(): void
{
throw new LogicException('Logout is handled by the Symfony firewall.');
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Controller\Admin;
use App\Repository\ScriptMappingRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class DashboardController extends AbstractController
{
#[Route('/admin', name: 'admin_dashboard', methods: ['GET'])]
public function __invoke(ScriptMappingRepository $mappingRepository): Response
{
return $this->render('admin/dashboard.html.twig', [
'mappings' => $mappingRepository->findAllOrderedByPublicPath(),
]);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Controller\Admin;
use App\Entity\ScriptMapping;
use App\Exception\GitSyncFailedException;
use App\Form\ScriptMappingType;
use App\Service\GitSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/admin/mappings')]
final class ScriptMappingController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
#[Route('/new', name: 'admin_mapping_new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
$mapping = new ScriptMapping();
$form = $this->createForm(ScriptMappingType::class, $mapping);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$token = (string) $form->get('accessToken')->getData();
$mapping->setAccessToken($token);
$this->entityManager->persist($mapping);
$this->entityManager->flush();
$this->addFlash('success', 'Mapping créé.');
return $this->redirectToRoute('admin_dashboard');
}
return $this->render('admin/mapping_form.html.twig', [
'form' => $form,
'mapping' => $mapping,
'title' => 'Nouveau mapping',
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
}
#[Route('/{id}/edit', name: 'admin_mapping_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, ScriptMapping $mapping): Response
{
$form = $this->createForm(ScriptMappingType::class, $mapping);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$token = (string) $form->get('accessToken')->getData();
if ($token !== '') {
$mapping->setAccessToken($token);
}
$this->entityManager->flush();
$this->addFlash('success', 'Mapping mis à jour.');
return $this->redirectToRoute('admin_dashboard');
}
return $this->render('admin/mapping_form.html.twig', [
'form' => $form,
'mapping' => $mapping,
'title' => 'Modifier le mapping',
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
}
#[Route('/{id}/delete', name: 'admin_mapping_delete', methods: ['POST'])]
public function delete(Request $request, ScriptMapping $mapping): Response
{
if (!$this->isCsrfTokenValid('delete_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$this->entityManager->remove($mapping);
$this->entityManager->flush();
$this->addFlash('success', 'Mapping supprimé.');
return $this->redirectToRoute('admin_dashboard');
}
#[Route('/{id}/sync', name: 'admin_mapping_sync', methods: ['POST'])]
public function sync(Request $request, ScriptMapping $mapping, GitSynchronizer $synchronizer): Response
{
if (!$this->isCsrfTokenValid('sync_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
try {
$synchronizer->sync($mapping);
$this->addFlash('success', 'Mapping synchronisé.');
} catch (GitSyncFailedException) {
$this->addFlash('error', 'La synchronisation du mapping a échoué.');
}
$this->entityManager->flush();
return $this->redirectToRoute('admin_dashboard');
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Controller\Admin;
use App\Form\SettingsType;
use App\Service\AppSettings;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function __invoke(Request $request, AppSettings $settings, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(SettingsType::class, [
'rootRedirectUrl' => $settings->rootRedirectUrl(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$settings->setRootRedirectUrl($data['rootRedirectUrl'] ?? null);
$entityManager->flush();
$this->addFlash('success', 'Configuration mise à jour.');
return $this->redirectToRoute('admin_dashboard');
}
return $this->render('admin/settings.html.twig', [
'form' => $form,
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Controller;
use App\Service\AppSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Attribute\Route;
final class HomeController extends AbstractController
{
#[Route('/', name: 'home', methods: ['GET'])]
public function __invoke(AppSettings $settings): RedirectResponse
{
$redirectUrl = $settings->rootRedirectUrl();
if ($redirectUrl !== null) {
return new RedirectResponse($redirectUrl);
}
return $this->redirectToRoute('admin_dashboard');
}
}

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)),
]);
}
}

87
src/Entity/AppSetting.php Normal file
View file

@ -0,0 +1,87 @@
<?php
namespace App\Entity;
use App\Repository\AppSettingRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AppSettingRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\UniqueConstraint(name: 'uniq_app_setting_name', columns: ['name'])]
class AppSetting
{
public const ROOT_REDIRECT_URL = 'root_redirect_url';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 190)]
private string $name = '';
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $value = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?DateTimeImmutable $updatedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = trim($name);
return $this;
}
public function getValue(): ?string
{
return $this->value;
}
public function setValue(?string $value): self
{
$value = $value === null ? null : trim($value);
$this->value = $value === '' ? null : $value;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
#[ORM\PrePersist]
public function initializeTimestamps(): void
{
$now = new DateTimeImmutable();
$this->createdAt ??= $now;
$this->updatedAt ??= $now;
}
#[ORM\PreUpdate]
public function refreshUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}

View file

@ -80,6 +80,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
#[\Deprecated]
public function eraseCredentials(): void
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exception;
use RuntimeException;
final class GitSyncFailedException extends RuntimeException
{
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class LoginFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('username', TextType::class)
->add('password', PasswordType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_token_id' => 'authenticate',
]);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Form;
use App\Entity\ScriptMapping;
use App\Service\PathNormalizer;
use InvalidArgumentException;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ScriptMappingType extends AbstractType
{
public function __construct(private readonly PathNormalizer $pathNormalizer)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('publicPath', TextType::class)
->add('repositoryUrl', UrlType::class, [
'default_protocol' => null,
])
->add('gitRef', TextType::class)
->add('repositoryFilePath', TextType::class)
->add('accessToken', PasswordType::class, [
'mapped' => false,
'required' => false,
])
->add('active', CheckboxType::class, [
'required' => false,
])
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
$mapping = $event->getData();
if (!$mapping instanceof ScriptMapping) {
return;
}
$form = $event->getForm();
try {
$mapping->setPublicPath($this->pathNormalizer->publicPath($mapping->getPublicPath()));
} catch (InvalidArgumentException $exception) {
$form->get('publicPath')->addError(new FormError($exception->getMessage()));
}
try {
$mapping->setRepositoryFilePath($this->pathNormalizer->repositoryPath($mapping->getRepositoryFilePath()));
} catch (InvalidArgumentException $exception) {
$form->get('repositoryFilePath')->addError(new FormError($exception->getMessage()));
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ScriptMapping::class,
]);
}
}

34
src/Form/SettingsType.php Normal file
View file

@ -0,0 +1,34 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Url;
final class SettingsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('rootRedirectUrl', UrlType::class, [
'default_protocol' => null,
'required' => false,
'constraints' => [
new Url(
protocols: ['http', 'https'],
requireTld: false,
message: 'LURL de redirection doit être une URL http ou https.'
),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
]);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Repository;
use App\Entity\AppSetting;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
final class AppSettingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AppSetting::class);
}
public function getValue(string $name): ?string
{
return $this->findOneBy(['name' => $name])?->getValue();
}
public function setValue(string $name, ?string $value): AppSetting
{
$setting = $this->findOneBy(['name' => $name]) ?? (new AppSetting())->setName($name);
$setting->setValue($value);
$this->getEntityManager()->persist($setting);
return $setting;
}
}

View file

@ -20,4 +20,10 @@ final class ScriptMappingRepository extends ServiceEntityRepository
'active' => true,
]);
}
/** @return list<ScriptMapping> */
public function findAllOrderedByPublicPath(): array
{
return $this->findBy([], ['publicPath' => 'ASC']);
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
{
}
public function authenticate(Request $request): Passport
{
/** @var array{username?: string, password?: string, _token?: string} $data */
$data = $request->request->all('login_form');
$username = trim($data['username'] ?? '');
$request->getSession()->set('_security.last_username', $username);
return new Passport(
new UserBadge($username),
new PasswordCredentials($data['password'] ?? ''),
[
new CsrfTokenBadge('authenticate', $data['_token'] ?? ''),
],
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('admin_dashboard'));
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate('admin_login');
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Service;
use App\Entity\AppSetting;
use App\Repository\AppSettingRepository;
final class AppSettings
{
public function __construct(private readonly AppSettingRepository $settings)
{
}
public function rootRedirectUrl(): ?string
{
return $this->settings->getValue(AppSetting::ROOT_REDIRECT_URL);
}
public function setRootRedirectUrl(?string $url): void
{
$this->settings->setValue(AppSetting::ROOT_REDIRECT_URL, $url);
}
}

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,175 @@
<?php
namespace App\Service;
use App\Entity\ScriptMapping;
use App\Exception\GitSyncFailedException;
use LogicException;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Throwable;
final class GitSynchronizer
{
public function __construct(private readonly CachePathResolver $cachePathResolver)
{
}
public function sync(ScriptMapping $mapping): void
{
if ($mapping->getId() === null) {
throw new LogicException('Cannot synchronize a mapping before it is persisted.');
}
try {
$repositoryDir = $this->checkoutRepository($mapping);
$cacheKey = $this->cachePathResolver->scriptCacheKeyForMapping($mapping->getId());
$this->copyRepositoryFileToCache($mapping, $repositoryDir, $cacheKey);
$mapping->markSyncSucceeded($cacheKey);
} catch (Throwable $exception) {
$message = $this->sanitizeError($exception->getMessage(), $mapping->getAccessToken());
$mapping->markSyncFailed($message);
throw new GitSyncFailedException('Git synchronization failed.', 0, $exception);
}
}
private function checkoutRepository(ScriptMapping $mapping): string
{
$repositoryDir = $this->repositoryDir($mapping);
$this->ensureDirectory(dirname($repositoryDir));
$env = $this->gitEnvironment($mapping, $repositoryDir);
if (!is_dir($repositoryDir.'/.git')) {
$this->removeDirectory($repositoryDir);
$this->runGit(['git', 'clone', '--no-checkout', $mapping->getRepositoryUrl(), $repositoryDir], null, $env);
} else {
$this->runGit(['git', '-C', $repositoryDir, 'remote', 'set-url', 'origin', $mapping->getRepositoryUrl()], null, $env);
}
$this->runGit(['git', '-C', $repositoryDir, 'fetch', '--prune', '--tags', 'origin', $mapping->getGitRef()], null, $env);
$this->runGit(['git', '-C', $repositoryDir, 'checkout', '--force', 'FETCH_HEAD'], null, $env);
return $repositoryDir;
}
private function copyRepositoryFileToCache(ScriptMapping $mapping, string $repositoryDir, string $cacheKey): void
{
$repositoryBase = realpath($repositoryDir);
if ($repositoryBase === false) {
throw new RuntimeException(sprintf('Repository directory "%s" does not exist.', $repositoryDir));
}
$sourcePath = $repositoryDir.'/'.$mapping->getRepositoryFilePath();
$sourceRealPath = realpath($sourcePath);
if ($sourceRealPath === false || !str_starts_with($sourceRealPath, rtrim($repositoryBase, '/').'/')) {
throw new RuntimeException(sprintf('Repository file "%s" was not found.', $mapping->getRepositoryFilePath()));
}
if (!is_file($sourceRealPath) || !is_readable($sourceRealPath)) {
throw new RuntimeException(sprintf('Repository file "%s" is not readable.', $mapping->getRepositoryFilePath()));
}
$targetPath = $this->cachePathResolver->servedScriptPath($cacheKey);
$this->ensureDirectory(dirname($targetPath));
$temporaryPath = $targetPath.'.tmp.'.bin2hex(random_bytes(6));
if (!copy($sourceRealPath, $temporaryPath)) {
throw new RuntimeException(sprintf('Could not write cache file "%s".', $targetPath));
}
chmod($temporaryPath, 0644);
if (!rename($temporaryPath, $targetPath)) {
@unlink($temporaryPath);
throw new RuntimeException(sprintf('Could not publish cache file "%s".', $targetPath));
}
}
/** @param list<string> $command */
private function runGit(array $command, ?string $cwd, array $env): void
{
try {
(new Process($command, $cwd, $env))->mustRun();
} catch (ProcessFailedException $exception) {
throw new RuntimeException($exception->getMessage(), 0, $exception);
}
}
private function repositoryDir(ScriptMapping $mapping): string
{
return $this->cachePathResolver->baseDir().'/repos/'.$mapping->getId();
}
/** @return array<string, string> */
private function gitEnvironment(ScriptMapping $mapping, string $repositoryDir): array
{
$env = [
'GIT_CONFIG_COUNT' => '1',
'GIT_CONFIG_KEY_0' => 'safe.directory',
'GIT_CONFIG_VALUE_0' => $repositoryDir,
'GIT_TERMINAL_PROMPT' => '0',
];
if (!$mapping->hasAccessToken()) {
return $env;
}
$askPassPath = $this->cachePathResolver->baseDir().'/git-askpass.sh';
$this->ensureDirectory(dirname($askPassPath));
file_put_contents($askPassPath, <<<'SH'
#!/usr/bin/env sh
case "$1" in
*Username*) printf '%s\n' oauth2 ;;
*) printf '%s\n' "$GIT_ACCESS_TOKEN" ;;
esac
SH);
chmod($askPassPath, 0700);
$env['GIT_ASKPASS'] = $askPassPath;
$env['GIT_ACCESS_TOKEN'] = (string) $mapping->getAccessToken();
return $env;
}
private function sanitizeError(string $message, ?string $accessToken): string
{
if ($accessToken === null || $accessToken === '') {
return $message;
}
return str_replace($accessToken, '[redacted]', $message);
}
private function ensureDirectory(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException(sprintf('Could not create directory "%s".', $directory));
}
}
private function removeDirectory(string $directory): void
{
if (!is_dir($directory)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($directory);
}
}

View file

@ -0,0 +1,8 @@
{% extends 'base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('admin.css') }}">
{% endblock %}
{% block body_class %}admin-page{% endblock %}

View file

@ -0,0 +1,78 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Admin{% endblock %}
{% block body %}
<main class="admin-shell">
<header class="admin-toolbar">
<div>
<p class="eyebrow">Administration</p>
<h1>Mappings</h1>
</div>
<nav class="toolbar-actions" aria-label="Actions admin">
<a class="button button-primary" href="{{ path('admin_mapping_new') }}">Nouveau mapping</a>
<a class="button button-secondary" href="{{ path('admin_settings') }}">Configuration</a>
</nav>
</header>
{% for message in app.flashes('success') %}
<p class="alert alert-success">{{ message }}</p>
{% endfor %}
{% for message in app.flashes('error') %}
<p class="alert alert-error">{{ message }}</p>
{% endfor %}
<section class="table-panel" aria-label="Mappings">
<table class="admin-table">
<thead>
<tr>
<th>Chemin public</th>
<th>Dépôt</th>
<th>Réf.</th>
<th>Fichier</th>
<th>Actif</th>
<th>Dernière synchro</th>
<th>Dernier succès</th>
<th>Erreur</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for mapping in mappings %}
<tr>
<td><code>{{ mapping.publicPath }}</code></td>
<td class="text-muted">{{ mapping.repositoryUrl }}</td>
<td><code>{{ mapping.gitRef }}</code></td>
<td><code>{{ mapping.repositoryFilePath }}</code></td>
<td>
<span class="status-badge {{ mapping.active ? 'status-success' : 'status-muted' }}">
{{ mapping.active ? 'oui' : 'non' }}
</span>
</td>
<td>{{ mapping.lastSyncStatus ?: 'jamais' }}</td>
<td>{{ mapping.lastSuccessfulSyncAt ? mapping.lastSuccessfulSyncAt|date('Y-m-d H:i:s') : '' }}</td>
<td class="error-cell">{{ mapping.lastSyncError }}</td>
<td>
<div class="row-actions">
<a class="button button-ghost" href="{{ path('admin_mapping_edit', {id: mapping.id}) }}">Modifier</a>
<form method="post" action="{{ path('admin_mapping_sync', {id: mapping.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('sync_mapping_' ~ mapping.id) }}">
<button class="button button-secondary" type="submit">Synchroniser</button>
</form>
<form method="post" action="{{ path('admin_mapping_delete', {id: mapping.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete_mapping_' ~ mapping.id) }}">
<button class="button button-danger" type="submit">Supprimer</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td class="empty-state" colspan="9">Aucun mapping.</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Admin login{% endblock %}
{% block body_class %}admin-auth{% endblock %}
{% block body %}
<main class="auth-shell">
<section class="admin-card auth-card">
<p class="eyebrow">Administration</p>
<h1>Connexion</h1>
{% if error %}
<p class="alert alert-error">{{ error.messageKey|trans(error.messageData, 'security') }}</p>
{% endif %}
{{ form_start(loginForm, { attr: { class: 'admin-form' } }) }}
{{ form_row(loginForm.username) }}
{{ form_row(loginForm.password) }}
<button class="button button-primary" type="submit">Sign in</button>
{{ form_end(loginForm) }}
</section>
</main>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends 'admin/base.html.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
<main class="admin-shell admin-form-shell">
<header class="admin-toolbar">
<div>
<p class="eyebrow">Administration</p>
<h1>{{ title }}</h1>
</div>
<a class="button button-secondary" href="{{ path('admin_dashboard') }}">Retour aux mappings</a>
</header>
<section class="admin-card">
{{ form_start(form, { attr: { class: 'admin-form' } }) }}
{{ form_row(form.publicPath) }}
{{ form_row(form.repositoryUrl) }}
{{ form_row(form.gitRef) }}
{{ form_row(form.repositoryFilePath) }}
{{ form_row(form.accessToken) }}
{{ form_row(form.active) }}
<button class="button button-primary" type="submit">Enregistrer</button>
{{ form_end(form) }}
</section>
</main>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Configuration{% endblock %}
{% block body %}
<main class="admin-shell admin-form-shell">
<header class="admin-toolbar">
<div>
<p class="eyebrow">Administration</p>
<h1>Configuration</h1>
</div>
<a class="button button-secondary" href="{{ path('admin_dashboard') }}">Retour aux mappings</a>
</header>
<section class="admin-card">
{{ form_start(form, { attr: { class: 'admin-form' } }) }}
{{ form_row(form.rootRedirectUrl, {
label: 'URL de redirection de /',
help: 'Laisser vide pour rediriger vers /admin.'
}) }}
<button class="button button-primary" type="submit">Enregistrer</button>
{{ form_end(form) }}
</section>
</main>
{% endblock %}

View file

@ -17,7 +17,7 @@
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
{% block body %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,139 @@
<?php
namespace App\Tests\Command;
use App\Entity\ScriptMapping;
use App\Tests\DatabaseWebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
final class SyncScriptMappingsCommandTest extends DatabaseWebTestCase
{
private Filesystem $filesystem;
private string $workDir;
protected function setUp(): void
{
parent::setUp();
$this->filesystem = new Filesystem();
$this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-command-test-'.bin2hex(random_bytes(6));
$this->filesystem->mkdir($this->workDir);
}
protected function tearDown(): void
{
$this->filesystem->remove($this->workDir);
parent::tearDown();
}
public function testCommandSynchronizesEveryMapping(): void
{
$firstRepositoryDir = $this->createRepository('first', [
'install.sh' => "#!/usr/bin/env bash\nprintf 'first'\n",
]);
$secondRepositoryDir = $this->createRepository('second', [
'install.sh' => "#!/usr/bin/env bash\nprintf 'second'\n",
]);
$firstMapping = $this->mapping('mcp/first/install.sh', $firstRepositoryDir);
$secondMapping = $this->mapping('mcp/second/install.sh', $secondRepositoryDir);
$this->entityManager->persist($firstMapping);
$this->entityManager->persist($secondMapping);
$this->entityManager->flush();
$application = new Application(static::$kernel);
self::assertTrue($application->has('app:mappings:sync'));
$commandTester = new CommandTester($application->find('app:mappings:sync'));
$commandTester->execute([]);
self::assertSame(0, $commandTester->getStatusCode());
self::assertStringContainsString('2 mapping(s) synchronized, 0 failed.', $commandTester->getDisplay());
$this->entityManager->clear();
$syncedFirstMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($firstMapping->getId());
$syncedSecondMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($secondMapping->getId());
self::assertInstanceOf(ScriptMapping::class, $syncedFirstMapping);
self::assertInstanceOf(ScriptMapping::class, $syncedSecondMapping);
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $syncedFirstMapping->getLastSyncStatus());
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $syncedSecondMapping->getLastSyncStatus());
}
public function testCommandContinuesAfterMappingFailureAndReturnsFailure(): void
{
$failingRepositoryDir = $this->createRepository('failing', [
'install.sh' => "#!/usr/bin/env bash\nprintf 'missing target'\n",
]);
$workingRepositoryDir = $this->createRepository('working', [
'install.sh' => "#!/usr/bin/env bash\nprintf 'working'\n",
]);
$failingMapping = $this->mapping('mcp/a-failing/install.sh', $failingRepositoryDir)
->setRepositoryFilePath('missing.sh');
$workingMapping = $this->mapping('mcp/b-working/install.sh', $workingRepositoryDir);
$this->entityManager->persist($failingMapping);
$this->entityManager->persist($workingMapping);
$this->entityManager->flush();
$application = new Application(static::$kernel);
self::assertTrue($application->has('app:mappings:sync'));
$commandTester = new CommandTester($application->find('app:mappings:sync'));
$commandTester->execute([]);
self::assertSame(1, $commandTester->getStatusCode());
self::assertStringContainsString('1 mapping(s) synchronized, 1 failed.', $commandTester->getDisplay());
$this->entityManager->clear();
$syncedFailingMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($failingMapping->getId());
$syncedWorkingMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($workingMapping->getId());
self::assertInstanceOf(ScriptMapping::class, $syncedFailingMapping);
self::assertInstanceOf(ScriptMapping::class, $syncedWorkingMapping);
self::assertSame(ScriptMapping::SYNC_STATUS_FAILED, $syncedFailingMapping->getLastSyncStatus());
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $syncedWorkingMapping->getLastSyncStatus());
}
private function mapping(string $publicPath, string $repositoryDir): ScriptMapping
{
return (new ScriptMapping())
->setPublicPath($publicPath)
->setRepositoryUrl($repositoryDir)
->setGitRef('main')
->setRepositoryFilePath('install.sh')
->setActive(true);
}
/** @param array<string, string> $files */
private function createRepository(string $name, array $files): string
{
$repositoryDir = $this->workDir.'/'.$name;
$this->filesystem->mkdir($repositoryDir);
foreach ($files as $path => $content) {
$fullPath = $repositoryDir.'/'.$path;
$this->filesystem->mkdir(dirname($fullPath));
file_put_contents($fullPath, $content);
}
$this->runGit(['init'], $repositoryDir);
$this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir);
$this->runGit(['config', 'user.name', 'Tests'], $repositoryDir);
$this->runGit(['add', '.'], $repositoryDir);
$this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir);
$this->runGit(['branch', '-M', 'main'], $repositoryDir);
return $repositoryDir;
}
/** @param list<string> $arguments */
private function runGit(array $arguments, string $cwd): void
{
(new Process(['git', ...$arguments], $cwd))->mustRun();
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Tests\Controller\Admin;
use App\Entity\User;
use App\Tests\DatabaseWebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class AuthControllerTest extends DatabaseWebTestCase
{
public function testAdminRedirectsToLogin(): void
{
$this->client->request('GET', '/admin');
self::assertResponseRedirects('/admin/login');
}
public function testAdminCanLogin(): void
{
$container = static::getContainer();
$user = (new User())->setUsername('admin');
$hash = $container->get(UserPasswordHasherInterface::class)->hashPassword($user, 'secret-password');
$user->setPasswordHash($hash);
$this->entityManager->persist($user);
$this->entityManager->flush();
$crawler = $this->client->request('GET', '/admin/login');
$form = $crawler->selectButton('Sign in')->form([
'login_form[username]' => 'admin',
'login_form[password]' => 'secret-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/admin');
}
public function testLoginUsesUtilityAdminLayout(): void
{
$this->client->request('GET', '/admin/login');
self::assertSelectorExists('body.admin-auth');
self::assertSelectorExists('.admin-card');
self::assertSelectorExists('.button-primary');
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace App\Tests\Controller\Admin;
use App\Entity\ScriptMapping;
use App\Entity\User;
use App\Tests\DatabaseWebTestCase;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
final class ScriptMappingControllerTest extends DatabaseWebTestCase
{
private Filesystem $filesystem;
private string $workDir;
protected function setUp(): void
{
parent::setUp();
$this->filesystem = new Filesystem();
$this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-controller-test-'.bin2hex(random_bytes(6));
$this->filesystem->mkdir($this->workDir);
}
protected function tearDown(): void
{
$this->filesystem->remove($this->workDir);
parent::tearDown();
}
public function testNewMappingRequiresAuthentication(): void
{
$this->client->request('GET', '/admin/mappings/new');
self::assertResponseRedirects('/admin/login');
}
public function testDashboardUsesUtilityAdminLayout(): void
{
$this->loginAsAdmin();
$mapping = (new ScriptMapping())
->setPublicPath('mcp/graylog/install.sh')
->setRepositoryUrl('https://forge.lclr.dev/AI/graylog-mcp.git')
->setGitRef('main')
->setRepositoryFilePath('install.sh')
->setActive(true);
$this->entityManager->persist($mapping);
$this->entityManager->flush();
$this->client->request('GET', '/admin');
self::assertSelectorExists('body.admin-page');
self::assertSelectorExists('.admin-toolbar');
self::assertSelectorExists('.admin-table');
self::assertSelectorExists('.status-badge');
}
public function testAdminCanCreateMappingWithNormalizedPaths(): void
{
$this->loginAsAdmin();
$crawler = $this->client->request('GET', '/admin/mappings/new');
$form = $crawler->selectButton('Enregistrer')->form([
'script_mapping[publicPath]' => '/mcp/graylog/install.sh',
'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git',
'script_mapping[gitRef]' => 'main',
'script_mapping[repositoryFilePath]' => '/install.sh',
'script_mapping[active]' => '1',
]);
$this->client->submit($form);
self::assertResponseRedirects('/admin');
$mapping = $this->entityManager->getRepository(ScriptMapping::class)->findOneBy([
'publicPath' => 'mcp/graylog/install.sh',
]);
self::assertInstanceOf(ScriptMapping::class, $mapping);
self::assertSame('install.sh', $mapping->getRepositoryFilePath());
}
public function testInvalidPublicPathRendersValidationError(): void
{
$this->loginAsAdmin();
$crawler = $this->client->request('GET', '/admin/mappings/new');
$form = $crawler->selectButton('Enregistrer')->form([
'script_mapping[publicPath]' => 'mcp/graylog/install.txt',
'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git',
'script_mapping[gitRef]' => 'main',
'script_mapping[repositoryFilePath]' => 'install.sh',
'script_mapping[active]' => '1',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
self::assertSelectorTextContains('form', 'Public path must end with .sh.');
}
public function testAdminCanSynchronizeMapping(): void
{
$this->loginAsAdmin();
$repositoryDir = $this->createRepository([
'install.sh' => "#!/usr/bin/env bash\nprintf 'synced'\n",
]);
$mapping = (new ScriptMapping())
->setPublicPath('mcp/graylog/install.sh')
->setRepositoryUrl($repositoryDir)
->setGitRef('main')
->setRepositoryFilePath('install.sh')
->setActive(true);
$this->entityManager->persist($mapping);
$this->entityManager->flush();
$crawler = $this->client->request('GET', '/admin');
$form = $crawler->selectButton('Synchroniser')->form();
$this->client->submit($form);
self::assertResponseRedirects('/admin');
$syncedMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($mapping->getId());
self::assertInstanceOf(ScriptMapping::class, $syncedMapping);
self::assertSame(
ScriptMapping::SYNC_STATUS_SYNCED,
$syncedMapping->getLastSyncStatus(),
(string) $syncedMapping->getLastSyncError()
);
self::assertSame('scripts/'.$mapping->getId().'.sh', $syncedMapping->getCacheKey());
}
private function loginAsAdmin(): void
{
$user = (new User())->setUsername('admin')->setPasswordHash('unused');
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->client->loginUser($user);
}
/** @param array<string, string> $files */
private function createRepository(array $files): string
{
$repositoryDir = $this->workDir.'/repo';
$this->filesystem->mkdir($repositoryDir);
foreach ($files as $path => $content) {
$fullPath = $repositoryDir.'/'.$path;
$this->filesystem->mkdir(dirname($fullPath));
file_put_contents($fullPath, $content);
}
$this->runGit(['init'], $repositoryDir);
$this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir);
$this->runGit(['config', 'user.name', 'Tests'], $repositoryDir);
$this->runGit(['add', '.'], $repositoryDir);
$this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir);
$this->runGit(['branch', '-M', 'main'], $repositoryDir);
return $repositoryDir;
}
/** @param list<string> $arguments */
private function runGit(array $arguments, string $cwd): void
{
(new Process(['git', ...$arguments], $cwd))->mustRun();
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Tests\Controller\Admin;
use App\Entity\AppSetting;
use App\Entity\User;
use App\Tests\DatabaseWebTestCase;
final class SettingsControllerTest extends DatabaseWebTestCase
{
public function testSettingsRequireAuthentication(): void
{
$this->client->request('GET', '/admin/settings');
self::assertResponseRedirects('/admin/login');
}
public function testAdminCanUpdateRootRedirectUrl(): void
{
$this->loginAsAdmin();
$crawler = $this->client->request('GET', '/admin/settings');
$form = $crawler->selectButton('Enregistrer')->form([
'settings[rootRedirectUrl]' => 'https://example.com/installers',
]);
$this->client->submit($form);
self::assertResponseRedirects('/admin');
self::assertSame(
'https://example.com/installers',
$this->entityManager->getRepository(AppSetting::class)->getValue(AppSetting::ROOT_REDIRECT_URL)
);
}
public function testRootRedirectUrlMustBeHttpOrHttps(): void
{
$this->loginAsAdmin();
$crawler = $this->client->request('GET', '/admin/settings');
$form = $crawler->selectButton('Enregistrer')->form([
'settings[rootRedirectUrl]' => 'javascript:alert(1)',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
self::assertNull($this->entityManager->getRepository(AppSetting::class)->getValue(AppSetting::ROOT_REDIRECT_URL));
}
private function loginAsAdmin(): void
{
$user = (new User())->setUsername('admin')->setPasswordHash('unused');
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->client->loginUser($user);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Tests\Controller;
use App\Entity\AppSetting;
use App\Tests\DatabaseWebTestCase;
final class HomeControllerTest extends DatabaseWebTestCase
{
public function testRootRedirectsToAdminWhenNoRedirectUrlIsConfigured(): void
{
$this->client->request('GET', '/');
self::assertResponseRedirects('/admin');
}
public function testRootRedirectsToConfiguredUrl(): void
{
$setting = (new AppSetting())
->setName(AppSetting::ROOT_REDIRECT_URL)
->setValue('https://example.com/installers');
$this->entityManager->persist($setting);
$this->entityManager->flush();
$this->client->request('GET', '/');
self::assertResponseRedirects('https://example.com/installers');
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Tests\Controller;
use App\Entity\ScriptMapping;
use App\Tests\DatabaseWebTestCase;
final class PublicScriptControllerTest extends DatabaseWebTestCase
{
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());
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Tests;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class DatabaseWebTestCase extends WebTestCase
{
protected KernelBrowser $client;
protected 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();
}
}

View file

@ -0,0 +1,126 @@
<?php
namespace App\Tests\Service;
use App\Entity\ScriptMapping;
use App\Service\CachePathResolver;
use App\Service\GitSynchronizer;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
final class GitSynchronizerTest extends TestCase
{
private Filesystem $filesystem;
private string $workDir;
protected function setUp(): void
{
$this->filesystem = new Filesystem();
$this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-test-'.bin2hex(random_bytes(6));
$this->filesystem->mkdir($this->workDir);
}
protected function tearDown(): void
{
$this->filesystem->remove($this->workDir);
}
public function testSyncCopiesRepositoryScriptToCache(): void
{
$repositoryDir = $this->createRepository([
'install.sh' => "#!/usr/bin/env bash\nprintf 'ok'\n",
]);
$mapping = $this->mapping($repositoryDir, 'install.sh');
$synchronizer = $this->synchronizer();
$synchronizer->sync($mapping);
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $mapping->getLastSyncStatus());
self::assertNotNull($mapping->getLastSuccessfulSyncAt());
self::assertNull($mapping->getLastSyncError());
self::assertSame('scripts/42.sh', $mapping->getCacheKey());
self::assertSame(
"#!/usr/bin/env bash\nprintf 'ok'\n",
file_get_contents($this->workDir.'/cache/scripts/42.sh')
);
}
public function testSyncFailureMarksMappingWithoutTokenLeak(): void
{
$repositoryDir = $this->createRepository([
'install.sh' => "#!/usr/bin/env bash\n",
]);
$mapping = $this->mapping($repositoryDir, 'missing.sh')->setAccessToken('secret-token');
$synchronizer = $this->synchronizer();
$this->expectExceptionMessage('Git synchronization failed.');
try {
$synchronizer->sync($mapping);
} finally {
self::assertSame(ScriptMapping::SYNC_STATUS_FAILED, $mapping->getLastSyncStatus());
self::assertNotNull($mapping->getLastSyncError());
self::assertStringContainsString('missing.sh', $mapping->getLastSyncError());
self::assertStringNotContainsString('secret-token', $mapping->getLastSyncError());
}
}
/** @param array<string, string> $files */
private function createRepository(array $files): string
{
$repositoryDir = $this->workDir.'/repo';
$this->filesystem->mkdir($repositoryDir);
foreach ($files as $path => $content) {
$fullPath = $repositoryDir.'/'.$path;
$this->filesystem->mkdir(dirname($fullPath));
file_put_contents($fullPath, $content);
}
$this->runGit(['init'], $repositoryDir);
$this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir);
$this->runGit(['config', 'user.name', 'Tests'], $repositoryDir);
$this->runGit(['add', '.'], $repositoryDir);
$this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir);
$this->runGit(['branch', '-M', 'main'], $repositoryDir);
return $repositoryDir;
}
/** @param list<string> $arguments */
private function runGit(array $arguments, string $cwd): void
{
$process = new Process(['git', ...$arguments], $cwd);
$process->mustRun();
}
private function mapping(string $repositoryUrl, string $repositoryFilePath): ScriptMapping
{
$mapping = (new ScriptMapping())
->setPublicPath('mcp/graylog/install.sh')
->setRepositoryUrl($repositoryUrl)
->setGitRef('main')
->setRepositoryFilePath($repositoryFilePath)
->setActive(true);
return $this->setMappingId($mapping, 42);
}
private function synchronizer(): GitSynchronizer
{
return new GitSynchronizer(new CachePathResolver($this->workDir.'/cache', $this->workDir));
}
private function setMappingId(ScriptMapping $mapping, int $id): ScriptMapping
{
$property = new ReflectionProperty(ScriptMapping::class, 'id');
$property->setValue($mapping, $id);
return $mapping;
}
}