Commit 1cb77385 authored by Stephane Bouvry's avatar Stephane Bouvry
Browse files

Mise en place de chromium sandbox pour replacer WkhtmlToPDF,testé sur les feuilles de temps OK

Une commande à été ajoutée (check:pdf) pour générer un PDF d'exemple
La commande config:infos a été mise à jour :
 - Ajout d'un test sur la présence des paquets chromium/chromium-sandbox
 - Ajout d'un test pour gearman (ping du serveur et version installée)
parent df362444
Loading
Loading
Loading
Loading
+1 −1
Changes for config/autoload/global.php: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -121,7 +121,7 @@ return array(
        ],

        'htmltopdfrenderer' => [
            'class' => \Oscar\Formatter\File\HtmlToPdfWkhtmltopdfFormatter::class,
            'class' => \Oscar\Formatter\File\HtmlToPdfChromiumFormatter::class,
            'arguments' => []
        ],

+6 −1
Changes for docker/app_Dockerfile: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -101,6 +101,11 @@ RUN rm /tmp/instantclient-sqlplus-linux.x64-18.5.0.0.0dbru.zip
# librairie wkhtmltopdf : Génération des PDF
RUN apt install -y wkhtmltopdf

# librairie chromium : Génération des PDF
RUN apt-get update \
    && apt-get install -y --no-install-recommends chromium chromium-sandbox \
    && rm -rf /var/lib/apt/lists/*

# Installation de NodeJS 22 LTS \
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
RUN apt-get install -y nodejs
@@ -133,6 +138,6 @@ WORKDIR /var/application
EXPOSE 9000


COPY app_entrypoint.sh /entrypoint.sh
COPY ./app_entrypoint.sh /entrypoint.sh

RUN chmod +x /entrypoint.sh
+1 −1
Changes for docker/dev/vite/Dockerfile: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@ FROM node:22-slim

LABEL authors="Stéphane BOUVRY<stephane.bouvry@unicaen.fr>"

COPY docker/dev/vite/entrypoint.sh /entrypoint.sh
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

WORKDIR /var/application/ui
+230 −0
Changes for module/Oscar/src/Oscar/Command/OscarCheckPdfCommand.php: 230 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace Oscar\Command;

use Oscar\Formatter\File\HtmlToPdfChromiumFormatter;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class OscarCheckPdfCommand extends OscarCommandAbstract
{
    protected static $defaultName = 'check:pdf';

    protected function configure()
    {
        $this
            ->setDescription('Génère un PDF de diagnostic avec Chromium')
            ->addOption(
                'pdf-name',
                null,
                InputOption::VALUE_REQUIRED,
                'Chemin du fichier PDF à générer',
                'oscar-pdf-check.pdf'
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $pdfPath = $input->getOption('pdf-name');
        if (!$this->isAbsolutePath($pdfPath)) {
            $pdfPath = getcwd() . DIRECTORY_SEPARATOR . $pdfPath;
        }

        $directory = dirname($pdfPath);
        if (!is_dir($directory) || !is_writable($directory)) {
            $io->error(sprintf('Le dossier de destination n’existe pas ou n’est pas accessible en écriture : %s', $directory));
            return self::FAILURE;
        }

        try {
            $infos = $this->getConfigurationInfos();
            $html = $this->buildHtml($infos);

            $formatter = new HtmlToPdfChromiumFormatter();
            $formatter->setOrientation(HtmlToPdfChromiumFormatter::ORIENTATION_PORTRAIT);
            $pdf = $formatter->convert($html, basename($pdfPath), false);

            if ($pdf === null || file_put_contents($pdfPath, $pdf) === false) {
                $io->error(sprintf('Impossible d’écrire le PDF de diagnostic : %s', $pdfPath));
                return self::FAILURE;
            }
        } catch (\Throwable $exception) {
            $io->error('Échec du test PDF : ' . $exception->getMessage());
            return self::FAILURE;
        }

        $io->success(sprintf('PDF généré : %s', $pdfPath));
        return self::SUCCESS;
    }

    private function getConfigurationInfos(): array
    {
        $command = $this->getApplication()->find('config:infos');
        $commandInput = new ArrayInput(['--json' => true], $command->getDefinition());
        $commandOutput = new BufferedOutput();
        $exitCode = $command->run($commandInput, $commandOutput);

        if ($exitCode !== self::SUCCESS) {
            throw new \RuntimeException('La commande config:infos a échoué.');
        }

        $infos = json_decode($commandOutput->fetch(), true);
        if (!is_array($infos)) {
            throw new \RuntimeException('La commande config:infos n’a pas retourné un JSON valide.');
        }

        return $infos;
    }

    private function buildHtml(array $infos): string
    {
        $sections = '';
        foreach ($infos as $name => $value) {
            $sectionTitle = $this->escape($this->formatLabel((string) $name));
            $sections .= '<section><h2>' . $sectionTitle . '</h2>';
            $sections .= is_array($value)
                ? $this->renderTable($value)
                : '<p>' . $this->escape($this->formatValue($value)) . '</p>';
            $sections .= '</section>';
        }

        $title = $this->escape('Oscar — diagnostic PDF');

        return '<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <title>' . $title . '</title>
    <style>
        @page { size: A4 portrait; margin: 18mm; }
        body { color: #222; font: 12px sans-serif; }
        h1 { color: #174b68; font-size: 22px; }
        h2 { color: #174b68; font-size: 16px; margin: 24px 0 8px; border-bottom: 2px solid #174b68; padding-bottom: 4px; }
        h3 { color: #315d73; font-size: 13px; margin: 8px 0 4px; }
        p { color: #666; }
        table { width: 100%; border-collapse: collapse; margin: 6px 0 12px; table-layout: fixed; }
        th, td { border: 1px solid #ccd5da; padding: 5px 7px; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
        thead { display: table-header-group; }
        th { background: #e9f1f4; color: #173f52; }
        tbody tr:nth-child(even) { background: #f6f8f9; }
        tr { break-inside: avoid; }
        td table { font-size: 10px; }
        td table th { background: #f0f3f4; }
        .key-column { width: 28%; font-weight: bold; }
    </style>
</head>
<body>
    <h1>' . $title . '</h1>
    <p>Rapport de diagnostic généré le ' . $this->escape(date('d/m/Y H:i:s')) . '</p>
    ' . $sections . '
</body>
</html>';
    }

    private function renderTable(array $data): string
    {
        if ($data === []) {
            return '<p>Aucune donnée</p>';
        }

        if ($this->isRecordMap($data)) {
            return $this->renderRecordTable($data);
        }

        $rows = '';
        foreach ($data as $key => $value) {
            $label = is_int($key) ? '#' . ($key + 1) : $this->formatLabel((string) $key);
            $renderedValue = is_array($value)
                ? $this->renderTable($value)
                : $this->escape($this->formatValue($value));
            $rows .= '<tr><th class="key-column">' . $this->escape($label) . '</th><td>' . $renderedValue . '</td></tr>';
        }

        return '<table><tbody>' . $rows . '</tbody></table>';
    }

    /**
     * Render associative arrays of records (such as Composer packages) as a
     * table with one column per record field.
     */
    private function renderRecordTable(array $records): string
    {
        $fields = [];
        foreach ($records as $record) {
            foreach (array_keys($record) as $field) {
                if (!in_array($field, $fields, true)) {
                    $fields[] = $field;
                }
            }
        }

        $headers = '<th>Élément</th>';
        foreach ($fields as $field) {
            $headers .= '<th>' . $this->escape($this->formatLabel((string) $field)) . '</th>';
        }

        $rows = '';
        foreach ($records as $name => $record) {
            $rows .= '<tr><th>' . $this->escape((string) $name) . '</th>';
            foreach ($fields as $field) {
                $value = $record[$field] ?? null;
                $rows .= '<td>' . (is_array($value)
                    ? $this->renderTable($value)
                    : $this->escape($this->formatValue($value))) . '</td>';
            }
            $rows .= '</tr>';
        }

        return '<table><thead><tr>' . $headers . '</tr></thead><tbody>' . $rows . '</tbody></table>';
    }

    private function isRecordMap(array $data): bool
    {
        if (array_is_list($data) || $data === []) {
            return false;
        }

        foreach ($data as $record) {
            if (!is_array($record) || array_is_list($record)) {
                return false;
            }
        }

        return true;
    }

    private function formatLabel(string $label): string
    {
        return ucfirst(str_replace(['_', '-'], ' ', $label));
    }

    private function formatValue($value): string
    {
        if ($value === null || $value === '') {
            return 'Indisponible';
        }
        if (is_bool($value)) {
            return $value ? 'Oui' : 'Non';
        }
        if (is_scalar($value)) {
            return (string) $value;
        }

        return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
    }

    private function escape(string $value): string
    {
        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }

    private function isAbsolutePath(string $path): bool
    {
        return $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1);
    }
}
+118 −2
Changes for module/Oscar/src/Oscar/Command/OscarConfigInfosCommand.php: 118 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -30,10 +30,16 @@ class OscarConfigInfosCommand extends OscarCommandAbstract

    protected function execute(InputInterface $input, OutputInterface $output): int
    {

        // URL Elasticsearch
        /** @var OscarConfigurationService $config */
        $config = $this->getServicemanager()->get(OscarConfigurationService::class);

        // Gearman
        $gearmanUrl = $config->getConfigArray()['oscar']['gearman-job-server-host'] ?? null;
        $gearman = $this->getGearmanInfo($gearmanUrl);


        // URL Elasticsearch

        $elasticsearchParams = $config->getConfigArray()['oscar']['strategy']['activity']['search_engine']['params'] ?? [];
        $postgresqlDoctrineOrmDefaultParams = $config->getConfigArray()['doctrine']['connection']['orm_default'] ?? [];

@@ -72,6 +78,11 @@ class OscarConfigInfosCommand extends OscarCommandAbstract
            'nodejs' => $this->runVersionCommand('node --version'),
            'git' => $this->runVersionCommand('git --version'),
            'composer' => $this->runVersionCommand('composer --version'),
            'chromium' => $this->runVersionCommand('chromium --version'),
            'pdf_generator_packages' => [
                'chromium' => $this->getDebianPackageInfo('chromium'),
                'chromium-sandbox' => $this->getDebianPackageInfo('chromium-sandbox'),
            ],
        ];
        $disk = $this->getDiskInfo(getcwd());
        $server = $this->getServerInfo();
@@ -88,6 +99,7 @@ class OscarConfigInfosCommand extends OscarCommandAbstract
            'server' => $server,
            'elasticsearch' => $elasticsearch,
            'postgresql' => $postgresql,
            'gearman' => $gearman,
            'php' => [
                'version' => PHP_VERSION,
                'version_id' => PHP_VERSION_ID,
@@ -151,11 +163,23 @@ class OscarConfigInfosCommand extends OscarCommandAbstract
            ['Version' => $postgresql['version'] ?? 'indisponible']
        );

        $io->section('Gearman');
        $io->definitionList(
            ['Serveur' => $gearman['host'] ?? 'indisponible'],
            ['Port' => $gearman['port'] ?? 'indisponible'],
            ['État' => $gearman['status']],
            ['Version' => $gearman['version'] ?? 'indisponible'],
            ['Erreur' => $gearman['error'] ?? '']
        );

        $io->section('Tools');
        $io->definitionList(
            ['Node.js' => $tools['nodejs'] ?? 'indisponible'],
            ['Git' => $tools['git'] ?? 'indisponible'],
            ['Composer' => $tools['composer'] ?? 'indisponible'],
            ['Chromium' => $tools['chromium'] ?? 'indisponible'],
            ['Paquet chromium' => $this->formatPackageInfo($tools['pdf_generator_packages']['chromium'])],
            ['Paquet chromium-sandbox' => $this->formatPackageInfo($tools['pdf_generator_packages']['chromium-sandbox'])],
            ['PHP' => $infos['php']['version']],
            ['SAPI' => $infos['php']['sapi']],
            ['Zend' => $infos['php']['zend_version']],
@@ -209,6 +233,37 @@ class OscarConfigInfosCommand extends OscarCommandAbstract
        return trim($output[0]);
    }

    private function getDebianPackageInfo(string $package): array
    {
        $output = [];
        $exitCode = 1;
        $format = '${db:Status-Status}\\t${Version}\\n';
        exec(
            'dpkg-query -W -f=' . escapeshellarg($format) . ' ' . escapeshellarg($package) . ' 2>/dev/null',
            $output,
            $exitCode
        );

        if ($exitCode !== 0 || !isset($output[0])) {
            return ['status' => 'absent', 'version' => null];
        }

        $parts = explode("\t", trim($output[0]), 2);
        return [
            'status' => $parts[0] ?? 'inconnu',
            'version' => $parts[1] ?? null,
        ];
    }

    private function formatPackageInfo(array $packageInfo): string
    {
        if (($packageInfo['status'] ?? null) !== 'installed') {
            return 'absent';
        }

        return 'installé' . (!empty($packageInfo['version']) ? ' (' . $packageInfo['version'] . ')' : '');
    }

    private function getElasticsearchInfo(?string $url): array
    {
        $info = [
@@ -260,6 +315,67 @@ class OscarConfigInfosCommand extends OscarCommandAbstract
        return $info;
    }

    private function getGearmanInfo(?string $server): array
    {
        $info = [
            'server' => $server,
            'status' => 'indisponible',
            'version' => null,
        ];
        if (!$server) {
            $info['error'] = 'Serveur Gearman non configuré';
            return $info;
        }

        [$host, $port] = $this->parseGearmanAddress($server);
        $info['host'] = $host;
        $info['port'] = $port;

        $errno = 0;
        $error = '';
        $socket = @fsockopen($host, $port, $errno, $error, 2.0);
        if (!is_resource($socket)) {
            $info['error'] = $error !== '' ? $error : 'Connexion TCP impossible (code ' . $errno . ')';
            return $info;
        }

        try {
            stream_set_timeout($socket, 2);
            if (fwrite($socket, "version\n") === false) {
                $info['error'] = 'Impossible d’interroger la version du serveur Gearman';
                return $info;
            }

            $version = fgets($socket, 256);
            if ($version !== false) {
                $version = trim($version);
                if ($version !== '') {
                    $info['status'] = 'accessible';
                    $info['version'] = $version;
                }
            }
        } finally {
            fclose($socket);
        }

        if ($info['version'] === null) {
            $info['version_status'] = 'indisponible';
            $info['error'] = 'Le serveur TCP ne répond pas à la commande Gearman « version »';
        }

        return $info;
    }

    private function parseGearmanAddress(string $server): array
    {
        $server = trim($server);
        $parts = parse_url(str_contains($server, '://') ? $server : 'tcp://' . $server);
        $host = $parts['host'] ?? $server;
        $port = $parts['port'] ?? 4730;

        return [$host, (int) $port];
    }

    private function getDiskInfo(string $path): array
    {
        $total = @disk_total_space($path);
Loading