Skip to content

Commit 3dd2b81

Browse files
authored
Merge pull request #18 from HelloAsso/log-file-per-env
Improve stream creation and logging
2 parents 3ad7406 + 0480be8 commit 3dd2b81

13 files changed

Lines changed: 806 additions & 79 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Environnement : correspond au nom de la branche git ("main", "develop", etc.)
2+
APP_ENV=develop
3+
14
DBURL=localhost
25
DBPORT=3306
36
DBNAME=twitch

.github/workflows/php-prod.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ jobs:
6060
envkey_MANDRILL_API: ${{ secrets.PROD_MANDRILL_API }}
6161
envkey_API_KEY: ${{ secrets.PROD_API_KEY }}
6262
envkey_APP_VERSION: ${{ github.sha }}
63+
envkey_APP_ENV: ${{ github.ref_name }}
6364
file_name: .env
6465

6566
- name: Tar
@@ -74,6 +75,6 @@ jobs:
7475
run : |
7576
sshpass -p ${{ secrets.PROD_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} "cd /home/socialgo/www && tar xzvf artifact.tar.gz -C twitch-widget && rm artifact.tar.gz"
7677
77-
- name: Launch sql migrations
78-
run : |
79-
sshpass -p ${{ secrets.PROD_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} "cd /home/socialgo/www/twitch-widget && php migrations/run.php"
78+
# - name: Launch sql migrations
79+
# run : |
80+
# sshpass -p ${{ secrets.PROD_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} "cd /home/socialgo/www/twitch-widget && php migrations/run.php"

.github/workflows/php-sandbox.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ name: Build & deploy sandbox
33
on:
44
push:
55
branches: [ "develop" ]
6+
workflow_dispatch:
67

78
permissions:
89
contents: read
@@ -60,6 +61,7 @@ jobs:
6061
envkey_MANDRILL_API: ${{ secrets.SANDBOX_MANDRILL_API }}
6162
envkey_API_KEY: ${{ secrets.SANDBOX_API_KEY }}
6263
envkey_APP_VERSION: ${{ github.sha }}
64+
envkey_APP_ENV: ${{ github.ref_name }}
6365
file_name: .env
6466

6567
- name: Tar
@@ -74,7 +76,7 @@ jobs:
7476
run : |
7577
sshpass -p ${{ secrets.SANDBOX_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SANDBOX_SSH_USER }}@${{ secrets.SANDBOX_SSH_HOST }} "cd /home/socialgo/www && tar xzvf artifact.tar.gz -C twitch-widget-sandbox && rm artifact.tar.gz"
7678
77-
- name: Launch sql migrations
78-
run : |
79-
sshpass -p ${{ secrets.SANDBOX_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SANDBOX_SSH_USER }}@${{ secrets.SANDBOX_SSH_HOST }} "cd /home/socialgo/www/twitch-widget-sandbox && php migrations/run.php"
79+
# - name: Launch sql migrations
80+
# run : |
81+
# sshpass -p ${{ secrets.SANDBOX_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SANDBOX_SSH_USER }}@${{ secrets.SANDBOX_SSH_HOST }} "cd /home/socialgo/www/twitch-widget-sandbox && php migrations/run.php"
8082

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ public/node_modules/
55
log.txt
66
*.log
77

8+
.idea
9+
810
node_modules/

cron.php

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
]);
1919

2020
$logger = new Logger('cron');
21-
$logger->pushHandler(new StreamHandler(__DIR__ . '/logs/cron.log', Logger::DEBUG));
21+
$env = $_SERVER['APP_ENV'] ?? 'production';
22+
$logDir = __DIR__ . '/logs/' . $env;
23+
if (!is_dir($logDir)) {
24+
mkdir($logDir, 0755, true);
25+
}
26+
$logFile = $logDir . '/cron.log';
27+
$logger->pushHandler(new StreamHandler($logFile, Logger::DEBUG));
2228

2329
$accessTokenRepository = new AccessTokenRepository($pdo, $_SERVER['DBPREFIX']);
2430
$authorizationCodeRepository = new AuthorizationCodeRepository($pdo, $_SERVER['DBPREFIX']);
@@ -35,15 +41,35 @@
3541
$logger
3642
);
3743

38-
$tokens = $accessTokenRepository->getAccessTokensToRefresh();
44+
try {
45+
$tokens = $accessTokenRepository->getAccessTokensToRefresh();
3946

40-
echo count($tokens) . " token(s) à rafraîchir\n";
41-
foreach ($tokens as $token) {
42-
try {
43-
$apiWrapper->refreshToken($token->refresh_token, $token->organization_slug);
44-
echo "Token rafraîchi pour " . ($token->organization_slug ?? 'global') . "\n";
45-
} catch (Exception $e) {
46-
echo "Erreur pour " . ($token->organization_slug ?? 'global') . " : " . $e->getMessage() . "\n";
47-
$logger->error('Erreur refresh token pour ' . $token->organization_slug, ['exception' => $e]);
47+
$logger->info('Cron démarré. IP du serveur : ' . gethostbyname(gethostname()) . '. API_AUTH_URL : ' . $_SERVER['API_AUTH_URL']);
48+
echo count($tokens) . " token(s) à rafraîchir\n";
49+
foreach ($tokens as $token) {
50+
try {
51+
$apiWrapper->refreshToken($token->refresh_token, $token->organization_slug);
52+
echo "Token rafraîchi pour " . ($token->organization_slug ?? 'global') . "\n";
53+
$logger->info('Token rafraîchi avec succès pour ' . ($token->organization_slug ?? 'global'));
54+
} catch (Exception $e) {
55+
$isNetworkError = str_contains($e->getMessage(), 'cURL error 7') || str_contains($e->getMessage(), 'Connection refused');
56+
echo "Erreur pour " . ($token->organization_slug ?? 'global') . " : " . $e->getMessage() . "\n";
57+
if ($isNetworkError) {
58+
$logger->critical('Impossible de joindre l\'API (' . $_SERVER['API_AUTH_URL'] . '). Vérifiez que le serveur distant est accessible depuis cette machine.', [
59+
'server_ip' => gethostbyname(gethostname()),
60+
'organization_slug' => $token->organization_slug,
61+
]);
62+
} else {
63+
$logger->error('Erreur refresh token pour ' . $token->organization_slug, ['exception' => $e]);
64+
}
65+
}
4866
}
67+
} catch (Throwable $e) {
68+
$logger->critical('Le cron a planté de manière inattendue.', [
69+
'exception' => $e->getMessage(),
70+
'file' => $e->getFile(),
71+
'line' => $e->getLine(),
72+
]);
73+
echo "Erreur critique : " . $e->getMessage() . "\n";
74+
exit(1);
4975
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE {prefix}authorization_code MODIFY COLUMN organization_slug VARCHAR(255) NULL;
2+

public/index.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,24 @@
3737
$container = new Container();
3838
$container->set('logger.api', function () {
3939
$level = $_SERVER['LOGLEVEL'] ?? Logger::DEBUG;
40-
$logger = new Logger('api'); // le nom est ici !
41-
$logger->pushHandler(new RotatingFileHandler(__DIR__ . '/../logs/api.log', 7, $level));
40+
$env = $_SERVER['APP_ENV'] ?? 'production';
41+
$logDir = __DIR__ . '/../logs/' . $env;
42+
if (!is_dir($logDir)) {
43+
mkdir($logDir, 0755, true);
44+
}
45+
$logger = new Logger('api');
46+
$logger->pushHandler(new RotatingFileHandler($logDir . '/api.log', 7, $level));
4247
return $logger;
4348
});
4449
$container->set(Logger::class, function () {
4550
$level = $_SERVER['LOGLEVEL'] ?? Logger::DEBUG;
51+
$env = $_SERVER['APP_ENV'] ?? 'production';
52+
$logDir = __DIR__ . '/../logs/' . $env;
53+
if (!is_dir($logDir)) {
54+
mkdir($logDir, 0755, true);
55+
}
4656
$logger = new Logger('app');
47-
$logger->pushHandler(new RotatingFileHandler(__DIR__ . '/../logs/app.log', 7, $level));
57+
$logger->pushHandler(new RotatingFileHandler($logDir . '/app.log', 7, $level));
4858
return $logger;
4959
});
5060

@@ -169,6 +179,16 @@
169179
} else {
170180
$errorMiddleware = $app->addErrorMiddleware(false, true, true, $container->get(Logger::class));
171181
}
182+
183+
// Ne pas logger les erreurs 404 (bots, scanners, etc.)
184+
$errorMiddleware->setErrorHandler(
185+
\Slim\Exception\HttpNotFoundException::class,
186+
function (\Psr\Http\Message\ServerRequestInterface $request, \Throwable $exception, bool $displayErrorDetails) use ($app) {
187+
$response = $app->getResponseFactory()->createResponse();
188+
return $response->withStatus(404);
189+
},
190+
true // handleSubclasses
191+
);
172192
$app->get('/', [HomeController::class, 'index'])->setName('app_index');
173193
$app->get('/forgot_password', [HomeController::class, 'forgotPassword'])->setName('app_forgot_password');
174194
$app->get('/reset_password/{token}', [HomeController::class, 'resetPassword'])->setName('app_reset_password');
@@ -186,6 +206,8 @@
186206
$app->get('/admin/event/{id}/edit', [AdminController::class, 'editEvent'])->add(new AuthMiddleware())->setName('app_event_edit');
187207
$app->post('/admin/event/{id}/edit', [AdminController::class, 'editEventPost'])->add(new AuthMiddleware())->setName('app_event_edit_post');
188208
$app->post('/admin/stream', [AdminController::class, 'newStream'])->add(new AuthMiddleware())->setName('app_stream_new');
209+
$app->get('/admin/stream/init-auth', [AdminController::class, 'initStreamAuth'])->add(new AuthMiddleware())->setName('app_stream_init_auth');
210+
$app->get('/admin/stream/auth-callback', [AdminController::class, 'streamAuthCallback'])->setName('app_stream_auth_callback');
189211
$app->post('/admin/stream/{id}/delete', [AdminController::class, 'deleteStream'])->add(new AuthMiddleware())->setName('app_stream_delete');
190212
$app->get('/admin/stream/{id}/edit', [AdminController::class, 'editStream'])->add(new AuthMiddleware())->setName('app_stream_edit');
191213
$app->post('/admin/stream/{id}/edit', [AdminController::class, 'editStreamPost'])->add(new AuthMiddleware())->setName('app_stream_edit_post');

src/Controllers/AdminController.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
namespace App\Controllers;
44

5+
use App\Models\AccessToken;
6+
use App\Repositories\AccessTokenRepository;
7+
use App\Repositories\AuthorizationCodeRepository;
58
use App\Repositories\EventRepository;
69
use App\Repositories\FileManager;
710
use App\Repositories\StreamRepository;
811
use App\Repositories\UserRepository;
912
use App\Repositories\WidgetRepository;
13+
use App\Services\ApiWrapper;
14+
use DateInterval;
15+
use DateTime;
16+
use Exception;
1017
use Psr\Http\Message\ResponseInterface as Response;
1118
use Psr\Http\Message\ServerRequestInterface as Request;
1219
use Slim\Flash\Messages;
@@ -23,6 +30,9 @@ public function __construct(
2330
private UserRepository $userRepository,
2431
private WidgetRepository $widgetRepository,
2532
private Messages $messages,
33+
private ApiWrapper $apiWrapper,
34+
private AccessTokenRepository $accessTokenRepository,
35+
private AuthorizationCodeRepository $authorizationCodeRepository,
2636
) {}
2737

2838
public function index(Request $request, Response $response): Response
@@ -40,6 +50,7 @@ public function index(Request $request, Response $response): Response
4050
"streams" => $streams,
4151
"events" => $events,
4252
"messages" => $this->messages->getMessages(),
53+
"currentUser" => $user,
4354
];
4455

4556
$template = $user->role == "ADMIN" ? 'stream/index-admin.html.twig' : 'stream/index.html.twig';
@@ -238,4 +249,139 @@ public function editStreamPost(Request $request, Response $response, array $args
238249

239250
return $response->withHeader('Location', $url)->withStatus(302);
240251
}
252+
253+
/**
254+
* Génère l'URL d'autorisation OAuth HelloAsso pour connecter une association dans le cadre de la création d'un stream.
255+
* Retourne un JSON { "url": "..." } pour que le front puisse ouvrir la mire dans un nouvel onglet.
256+
*
257+
* @param Request $request
258+
* @param Response $response
259+
* @return Response
260+
*/
261+
public function initStreamAuth(Request $request, Response $response): Response
262+
{
263+
$streamCallbackUrl = $_SERVER['WEBSITE_DOMAIN'] . '/admin/stream/auth-callback';
264+
$authorizationUrl = $this->apiWrapper->generateAuthorizationUrl(null, $streamCallbackUrl);
265+
266+
$response->getBody()->write(json_encode(['url' => $authorizationUrl]));
267+
return $response->withHeader('Content-Type', 'application/json');
268+
}
269+
270+
/**
271+
* Callback OAuth pour la connexion d'une association lors de la création d'un stream.
272+
* Échange le code d'autorisation, stocke les tokens et récupère la liste des formulaires de don.
273+
* Retourne une page HTML qui transmet les données à la fenêtre parente via postMessage.
274+
*
275+
* @param Request $request
276+
* @param Response $response
277+
* @return Response
278+
*/
279+
public function streamAuthCallback(Request $request, Response $response): Response
280+
{
281+
$error = $request->getQueryParams()['error'] ?? null;
282+
$errorDescription = $request->getQueryParams()['error_description'] ?? null;
283+
284+
if ($error) {
285+
$response->getBody()->write($this->buildCallbackPage(null, [], $errorDescription));
286+
return $response;
287+
}
288+
289+
$state = $request->getQueryParams()['state'] ?? null;
290+
$code = $request->getQueryParams()['code'] ?? null;
291+
292+
if (!$state || !$code) {
293+
$response->getBody()->write($this->buildCallbackPage(null, [], 'Paramètres manquants dans la réponse.'));
294+
return $response;
295+
}
296+
297+
try {
298+
$authorizationCodeData = $this->authorizationCodeRepository->selectById($state);
299+
if (!$authorizationCodeData) {
300+
throw new Exception("State invalide ou expiré.");
301+
}
302+
303+
$tokenData = $this->apiWrapper->exchangeAuthorizationCode(
304+
$code,
305+
$authorizationCodeData->redirect_uri,
306+
$authorizationCodeData->code_verifier
307+
);
308+
309+
$organizationSlug = $tokenData['organization_slug'];
310+
311+
// Stocker / mettre à jour les tokens
312+
$existingToken = $this->accessTokenRepository->selectBySlug($organizationSlug);
313+
314+
$token = new AccessToken();
315+
$token->access_token = $tokenData['access_token'];
316+
$token->refresh_token = $tokenData['refresh_token'];
317+
$token->organization_slug = $organizationSlug;
318+
$token->access_token_expires_at = (new DateTime())->add(new DateInterval('PT28M'));
319+
$token->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P28D'));
320+
321+
if ($existingToken === null) {
322+
$this->accessTokenRepository->insert($token);
323+
} else {
324+
$this->accessTokenRepository->update($token);
325+
}
326+
327+
// Récupérer les formulaires de don
328+
$forms = $this->apiWrapper->getDonationForms($organizationSlug);
329+
} catch (Exception $e) {
330+
$response->getBody()->write($this->buildCallbackPage(null, [], $e->getMessage()));
331+
return $response;
332+
}
333+
334+
$response->getBody()->write($this->buildCallbackPage($organizationSlug, $forms));
335+
return $response;
336+
}
337+
338+
/**
339+
* Construit la page HTML de callback OAuth qui communique les données à la fenêtre parente via postMessage.
340+
*/
341+
private function buildCallbackPage(?string $organizationSlug, array $forms, ?string $error = null): string
342+
{
343+
$organizationSlugJson = json_encode($organizationSlug);
344+
$formsJson = json_encode($forms);
345+
$errorJson = json_encode($error);
346+
347+
return <<<HTML
348+
<!DOCTYPE html>
349+
<html lang="fr">
350+
<head>
351+
<meta charset="UTF-8">
352+
<title>Connexion HelloAsso</title>
353+
<style>
354+
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
355+
.card { background: #16213e; padding: 2rem; border-radius: 12px; text-align: center; max-width: 400px; }
356+
.success { color: #4ade80; font-size: 2rem; }
357+
.error { color: #f87171; }
358+
</style>
359+
</head>
360+
<body>
361+
<div class="card">
362+
<div id="msg"><p>Connexion en cours, veuillez patienter...</p></div>
363+
</div>
364+
<script>
365+
var organizationSlug = {$organizationSlugJson};
366+
var forms = {$formsJson};
367+
var error = {$errorJson};
368+
369+
if (error) {
370+
document.getElementById('msg').innerHTML = '<p class="error">❌ Erreur : ' + error + '</p><p>Vous pouvez fermer cet onglet.</p>';
371+
} else if (window.opener && !window.opener.closed) {
372+
window.opener.postMessage({
373+
type: 'ha_stream_auth_success',
374+
organizationSlug: organizationSlug,
375+
forms: forms
376+
}, window.location.origin);
377+
document.getElementById('msg').innerHTML = '<p class="success">✅</p><p>Association connectée ! Fermeture en cours...</p>';
378+
setTimeout(function() { window.close(); }, 1500);
379+
} else {
380+
document.getElementById('msg').innerHTML = '<p class="success">✅ Association connectée !</p><p>Vous pouvez fermer cet onglet et retourner à la page d\'administration.</p>';
381+
}
382+
</script>
383+
</body>
384+
</html>
385+
HTML;
386+
}
241387
}

src/Controllers/LoginController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ public function validateAuthPage(Request $request, Response $response): Response
228228
$codeVerifier = $authorizationCodeData->code_verifier;
229229

230230
$tokenDataGrantAuthorization = $this->apiWrapper->exchangeAuthorizationCode($code, $redirect_uri, $codeVerifier);
231+
232+
if ($authorizationCodeData->organization_slug !== $tokenDataGrantAuthorization['organization_slug']) {
233+
$this->logger->warning('Incohérence de slug lors de l\'échange du code d\'autorisation', [
234+
'slug_attendu' => $authorizationCodeData->organization_slug,
235+
'slug_reçu' => $tokenDataGrantAuthorization['organization_slug'],
236+
]);
237+
$response->getBody()->write('Erreur : le slug de l\'association ne correspond pas à celui attendu. L\'authentification a été annulée.');
238+
return $response->withStatus(400);
239+
}
240+
231241
$existingOrganizationToken = $this->accessTokenRepository->selectBySlug($tokenDataGrantAuthorization['organization_slug']);
232242

233243
$token = new AccessToken();

src/Repositories/FileManager.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@ public function uploadPicture(UploadedFileInterface $file): string
7373
return $fileName;
7474
}
7575

76-
public function getPictureUrl(string $fileName): string
76+
public function getPictureUrl(?string $fileName): ?string
7777
{
78+
if ($fileName === null) {
79+
return null;
80+
}
7881
return $this->blobUrl . '/images/charity_stream/' . $fileName;
7982
}
8083

@@ -90,8 +93,11 @@ public function uploadSound(UploadedFileInterface $file): string
9093
return $fileName;
9194
}
9295

93-
public function getSoundUrl(string $fileName): string
96+
public function getSoundUrl(?string $fileName): ?string
9497
{
98+
if ($fileName === null) {
99+
return null;
100+
}
95101
return $this->blobUrl . '/sounds/charity_stream/' . $fileName;
96102
}
97103
}

0 commit comments

Comments
 (0)