Fix SonarQube issues, store sessions in Redis, use direct analytics URLs

- ApiSandboxController: reduce scan() returns from 4 to 3 via ternary
- ApiDocController: add MIME_JSON constant, extract buildInsomniaRequest()
  and buildInsomniaBody() to reduce cognitive complexity
- Store sessions in Redis to fix SSO disconnect with 2 PHP replicas
  (round-robin load balancing caused session loss on filesystem storage)
- Configure session cookie: 24h lifetime, secure auto, samesite lax
- Replace Caddy analytics proxies (/stats/*, /assets/perf.js, /sperf)
  with direct URLs to tools-security.esy-web.dev and cloudflareinsights.com
- Update JS tests for new direct analytics URLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-24 09:21:19 +01:00
parent 867eaadddf
commit d44e75e3fd
9 changed files with 94 additions and 86 deletions

4
.env
View File

@@ -32,6 +32,10 @@ DATABASE_URL="postgresql://app:secret@database:5432/ecosplay?serverVersion=16&ch
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
###< symfony/messenger ### ###< symfony/messenger ###
###> session ###
SESSION_HANDLER_DSN=redis://redis:6379/sessions
###< session ###
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=smtp://mailpit:1025 MAILER_DSN=smtp://mailpit:1025
###< symfony/mailer ### ###< symfony/mailer ###

View File

@@ -12,5 +12,6 @@ STRIPE_WEBHOOK_SECRET=whsec_test
STRIPE_WEBHOOK_SECRET_CONNECT=whsec_test_connect STRIPE_WEBHOOK_SECRET_CONNECT=whsec_test_connect
OUTSIDE_URL=https://test.example.com OUTSIDE_URL=https://test.example.com
MESSENGER_TRANSPORT_DSN=redis://:e_ticket@redis:6379/messages MESSENGER_TRANSPORT_DSN=redis://:e_ticket@redis:6379/messages
SESSION_HANDLER_DSN=redis://:e_ticket@redis:6379/sessions
SMIME_PASSPHRASE=test SMIME_PASSPHRASE=test
ADMIN_EMAIL=contact@test.com ADMIN_EMAIL=contact@test.com

View File

@@ -9,27 +9,6 @@ ticket.e-cosplay.fr {
file_server file_server
} }
handle_path /stats/* {
rewrite * {uri}
reverse_proxy https://tools-security.esy-web.dev {
header_up Host tools-security.esy-web.dev
}
}
handle /assets/perf.js {
rewrite * /beacon.min.js
reverse_proxy https://static.cloudflareinsights.com {
header_up Host static.cloudflareinsights.com
}
}
handle_path /sperf {
rewrite * /cdn-cgi/rum
reverse_proxy https://cloudflareinsights.com {
header_up Host cloudflareinsights.com
}
}
@maintenance file /var/www/e-ticket/public/.update @maintenance file /var/www/e-ticket/public/.update
handle @maintenance { handle @maintenance {
root * /var/www/e-ticket/public root * /var/www/e-ticket/public

View File

@@ -24,9 +24,9 @@ function loadAnalytics() {
const script = document.createElement('script') const script = document.createElement('script')
script.defer = true script.defer = true
script.src = '/stats/script.js' script.src = 'https://tools-security.esy-web.dev/script.js'
script.dataset.websiteId = 'a1f85dd5-741f-4df7-840a-7ef0931ed0cc' script.dataset.websiteId = 'a1f85dd5-741f-4df7-840a-7ef0931ed0cc'
script.dataset.hostUrl = '/stats' script.dataset.hostUrl = 'https://tools-security.esy-web.dev'
script.dataset.analytics = '1' script.dataset.analytics = '1'
document.head.appendChild(script) document.head.appendChild(script)
@@ -40,7 +40,7 @@ function loadCloudflareTunnel() {
const script = document.createElement('script') const script = document.createElement('script')
script.defer = true script.defer = true
script.src = '/assets/perf.js' script.src = 'https://static.cloudflareinsights.com/beacon.min.js'
script.dataset.cfBeacon = '{"token":"5f2f3b8e1f824be6984a348fe31d2f04","spa":true}' script.dataset.cfBeacon = '{"token":"5f2f3b8e1f824be6984a348fe31d2f04","spa":true}'
document.head.appendChild(script) document.head.appendChild(script)
} }

View File

@@ -3,7 +3,11 @@ framework:
secret: '%env(APP_SECRET)%' secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it. # Note that the session will be started ONLY if you read or write from it.
session: true session:
handler_id: '%env(SESSION_HANDLER_DSN)%'
cookie_lifetime: 86400
cookie_secure: auto
cookie_samesite: lax
#esi: true #esi: true
#fragments: true #fragments: true

View File

@@ -1271,9 +1271,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lifetime?: int|Param, // Default: 31536000 * lifetime?: int|Param, // Default: 31536000
* path?: scalar|Param|null, // Default: "/" * path?: scalar|Param|null, // Default: "/"
* domain?: scalar|Param|null, // Default: null * domain?: scalar|Param|null, // Default: null
* secure?: true|false|"auto"|Param, // Default: false * secure?: true|false|"auto"|Param, // Default: null
* httponly?: bool|Param, // Default: true * httponly?: bool|Param, // Default: true
* samesite?: null|"lax"|"strict"|"none"|Param, // Default: null * samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
* always_remember_me?: bool|Param, // Default: false * always_remember_me?: bool|Param, // Default: false
* remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me"
* }, * },

View File

@@ -119,13 +119,9 @@ class ApiSandboxController extends AbstractController
$fixtures = $this->loadFixtures(); $fixtures = $this->loadFixtures();
$result = $fixtures['scan'][$reference] ?? null; $result = $fixtures['scan'][$reference] ?? null;
if (!$result) { return $result
return $this->error('Billet introuvable.', 404); ? $this->success(array_diff_key($result, ['_comment' => true]))
} : $this->error('Billet introuvable.', 404);
unset($result['_comment']);
return $this->success($result);
} }
/** /**

View File

@@ -9,6 +9,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiDocController extends AbstractController class ApiDocController extends AbstractController
{ {
private const MIME_JSON = 'application/json';
private const STATUS_401 = 'Non authentifie'; private const STATUS_401 = 'Non authentifie';
private const STATUS_403_EVENT = 'Evenement non accessible'; private const STATUS_403_EVENT = 'Evenement non accessible';
private const STATUS_403_BILLET = 'Billet non accessible'; private const STATUS_403_BILLET = 'Billet non accessible';
@@ -74,55 +75,7 @@ class ApiDocController extends AbstractController
]; ];
foreach ($section['endpoints'] as $reqIndex => $endpoint) { foreach ($section['endpoints'] as $reqIndex => $endpoint) {
$isAuthRoute = str_starts_with($endpoint['path'], '/api/auth'); $resources[] = $this->buildInsomniaRequest($endpoint, $folderIndex, $reqIndex, $folderId);
$isLogin = '/api/auth/login' === $endpoint['path'];
$url = $isAuthRoute
? '{{ _.base_url }}'.$endpoint['path']
: '{{ _.base_url }}/api/{{ _.env }}'.str_replace('/api', '', $endpoint['path']);
$headers = [];
$headers[] = ['name' => 'Content-Type', 'value' => 'application/json'];
if (!$isLogin) {
$headers[] = ['name' => 'ETicket-Email', 'value' => '{{ _.email }}'];
$headers[] = ['name' => 'ETicket-JWT', 'value' => '{{ _.jwt_token }}'];
}
$body = null;
if ($isLogin) {
$body = [
'mimeType' => 'application/json',
'text' => json_encode([
'email' => '{{ _.email }}',
'password' => '{{ _.password }}',
], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE),
];
} elseif ($endpoint['request']) {
$example = [];
foreach ($endpoint['request'] as $name => $field) {
$example[$name] = $field['example'] ?? '';
}
$body = [
'mimeType' => 'application/json',
'text' => json_encode($example, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE),
];
}
$req = [
'_type' => 'request',
'_id' => 'req_'.$folderIndex.'_'.$reqIndex,
'parentId' => $folderId,
'name' => $endpoint['summary'],
'method' => $endpoint['method'],
'url' => $url,
'headers' => $headers,
'body' => $body,
];
if ($isAuthRoute) {
$req['afterResponseScript'] = "const res = insomnia.response.json();\nif (res && res.success && res.data && res.data.token) {\n insomnia.environment.set('jwt_token', res.data.token);\n}";
}
$resources[] = $req;
} }
} }
@@ -138,12 +91,83 @@ class ApiDocController extends AbstractController
json_encode($export, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES), json_encode($export, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES),
200, 200,
[ [
'Content-Type' => 'application/json', 'Content-Type' => self::MIME_JSON,
'Content-Disposition' => 'attachment; filename="eticket-api-insomnia.json"', 'Content-Disposition' => 'attachment; filename="eticket-api-insomnia.json"',
] ]
); );
} }
/**
* @param array<string, mixed> $endpoint
*
* @return array<string, mixed>
*/
private function buildInsomniaRequest(array $endpoint, int $folderIndex, int $reqIndex, string $folderId): array
{
$isAuthRoute = str_starts_with($endpoint['path'], '/api/auth');
$isLogin = '/api/auth/login' === $endpoint['path'];
$url = $isAuthRoute
? '{{ _.base_url }}'.$endpoint['path']
: '{{ _.base_url }}/api/{{ _.env }}'.str_replace('/api', '', $endpoint['path']);
$headers = [['name' => 'Content-Type', 'value' => self::MIME_JSON]];
if (!$isLogin) {
$headers[] = ['name' => 'ETicket-Email', 'value' => '{{ _.email }}'];
$headers[] = ['name' => 'ETicket-JWT', 'value' => '{{ _.jwt_token }}'];
}
$body = $this->buildInsomniaBody($endpoint, $isLogin);
$req = [
'_type' => 'request',
'_id' => 'req_'.$folderIndex.'_'.$reqIndex,
'parentId' => $folderId,
'name' => $endpoint['summary'],
'method' => $endpoint['method'],
'url' => $url,
'headers' => $headers,
'body' => $body,
];
if ($isAuthRoute) {
$req['afterResponseScript'] = "const res = insomnia.response.json();\nif (res && res.success && res.data && res.data.token) {\n insomnia.environment.set('jwt_token', res.data.token);\n}";
}
return $req;
}
/**
* @param array<string, mixed> $endpoint
*
* @return array{mimeType: string, text: string}|null
*/
private function buildInsomniaBody(array $endpoint, bool $isLogin): ?array
{
if ($isLogin) {
return [
'mimeType' => self::MIME_JSON,
'text' => json_encode([
'email' => '{{ _.email }}',
'password' => '{{ _.password }}',
], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE),
];
}
if ($endpoint['request']) {
$example = [];
foreach ($endpoint['request'] as $name => $field) {
$example[$name] = $field['example'] ?? '';
}
return [
'mimeType' => self::MIME_JSON,
'text' => json_encode($example, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE),
];
}
return null;
}
/** /**
* @return list<array{name: string, slug: string, baseUrl: string, description: string, badge: string, badgeColor: string}> * @return list<array{name: string, slug: string, baseUrl: string, description: string, badge: string, badgeColor: string}>
*/ */

View File

@@ -66,7 +66,7 @@ describe('initCookieConsent', () => {
document.getElementById('cookie-accept').click() document.getElementById('cookie-accept').click()
const script = document.querySelector('script[data-analytics]') const script = document.querySelector('script[data-analytics]')
expect(script).not.toBeNull() expect(script).not.toBeNull()
expect(script.src).toContain('/stats/script.js') expect(script.src).toContain('tools-security.esy-web.dev/script.js')
expect(script.dataset.websiteId).toBe('a1f85dd5-741f-4df7-840a-7ef0931ed0cc') expect(script.dataset.websiteId).toBe('a1f85dd5-741f-4df7-840a-7ef0931ed0cc')
}) })
@@ -105,7 +105,7 @@ describe('initCookieConsent', () => {
document.getElementById('cookie-accept').click() document.getElementById('cookie-accept').click()
const script = document.querySelector('script[data-cf-beacon]') const script = document.querySelector('script[data-cf-beacon]')
expect(script).not.toBeNull() expect(script).not.toBeNull()
expect(script.src).toContain('/assets/perf.js') expect(script.src).toContain('static.cloudflareinsights.com/beacon.min.js')
}) })
it('does not duplicate cloudflare script', () => { it('does not duplicate cloudflare script', () => {