<?php
namespace App\Controller;
use App\Entity\Card;
use App\Entity\User;
use App\Repository\CardRepository;
use App\Repository\CardTransactionRepository;
use App\Repository\UserRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Firebase\JWT\JWT;
use PKPass\PKPassException;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Monolog\Logger;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\Routing\Annotation\Route;
use PKPass\PKPass;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class AppleWalletController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly CardRepository $cardRepository,
private readonly CardTransactionRepository $cardTransactionRepository,
private readonly LoggerInterface $logger,
private readonly HttpClientInterface $httpClient,
private readonly Filesystem $fileSystem,
private readonly CsrfTokenManagerInterface $csrfTokenManager
)
{
}
/**
* @throws PKPassException
* @throws \Exception
*/
#[Route('/add-to-apple-wallet/{userSlug}', name: 'add_to_apple_wallet')]
public function addToAppleWallet(
string $userSlug,
Request $request
)
{
try {
if (!$this->getUser() || !$this->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Accès refusé. Vous devez être connecté.');
}
// Vérifier le jeton CSRF
$submittedToken = $request->query->get('_token');
if (!$this->isCsrfTokenValid('add-to-apple-wallet', $submittedToken)) {
throw new AccessDeniedException('Invalid CSRF token.');
}
$certificatePath = $this->getParameter('pass_certificate_path');
$certificatePassword = $this->getParameter('pass_certificate_password');
$pass = new PKPass($certificatePath, $certificatePassword);
$imagesDir = $this->getParameter('kernel.project_dir') . '/public/images/apple-wallet/';
$requiredImages = ['icon.png', 'icon@2x.png', 'icon@3x.png', 'logo.png', 'logo@2x.png', 'logo@3x.png', 'strip.png', 'strip@2x.png', 'strip@3x.png'];
foreach ($requiredImages as $image) {
$imagePath = $imagesDir . $image;
if (file_exists($imagePath)) {
$pass->addFile($imagePath);
} else {
throw new \Exception("Required image file not found: $image");
}
}
// Vérifiez que le chemin pointe vers un fichier
if (!is_file($imagePath)) {
throw new \Exception("Le fichier $imagePath n'existe pas ou n'est pas un fichier.");
}
// Ajoutez l'image au package Pass
$user = $this->userRepository->findOneBy(['slug' => $userSlug]);
if (!$user) {
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$card = $user->getCard();
if (!$card) {
return new JsonResponse(['error' => 'Card not found'], Response::HTTP_NOT_FOUND);
}
$slug = $user->getSlug();
$dernierPassage = $this->cardTransactionRepository->getDernierPassage($user)?->getTransactionDate();
if (!$card->getAppleWalletToken()) {
$card->generateAppleWalletToken();
$this->entityManager->persist($card);
$this->entityManager->flush();
}
$data = [
'formatVersion' => 1,
'passTypeIdentifier' => 'pass.com.immybeauty.pkpass',
'teamIdentifier' => 'V9N5857WGW',
'serialNumber' => $slug,
'webServiceURL' => 'https://immybeauty.fr/api/v1/passes',
'authenticationToken' => $card->getAppleWalletToken(),
'pushToken' => '', // Ce champ sera rempli par Apple lors de l'enregistrement du pass
'allowsUpdates' => true,
'sharingProhibited' => false,
"barcodes" => [
[
"message" => $this->generateQRCodeURLProfil($user),
"format" => "PKBarcodeFormatQR",
"messageEncoding" => "iso-8859-1"
]
],
"organizationName" => "IMMY BEAUTY",
"description" => "IMMY BEAUTY",
"foregroundColor" => "rgb(255, 255, 255)", // BLANC
"backgroundColor" => "rgb(20, 20, 20)", // NOIR
"labelColor" => "rgb(174, 136, 90)", // DORÉ
"relevantDate" => (new \DateTime())->format('c'), // Ajoutez cette ligne
"storeCard" => [
// 'primaryFields' => [
// [
// 'key' => 'notification',
// 'label' => 'Notification',
// 'value' => 'Votre pass est à jour',
// 'changeMessage' => 'Nouvelle notification : %@'
// ]
// ],
"headerFields" => [
[
"key" => "header-name",
"textAlignment" => "PKTextAlignmentRight",
"label" => "CLIENT",
"value" => $user->getFullName()
]
],
"auxiliaryFields" => [
[
"key" => "dernier-passage",
"label" => "DERNIER PASSAGE",
"value" => $dernierPassage ? $dernierPassage->format('d/m/Y') : '-',
"textAlignment" => "PKTextAlignmentLeft"
],
[
"key" => "points",
"label" => "POINTS",
"value" => $card->getPoints(),
"textAlignment" => "PKTextAlignmentRight"
]
],
"backFields" => [
[
"key" => "back-name",
"label" => "💇🏻♀️ SALON",
"value" => "IMMY BEAUTY"
],
[
"key" => "back-horaires",
"label" => "🕒 HORAIRES",
"value" => "Mar - Dim\n10h00 - 19h30"
],
[
"key" => "back-tel",
"label" => "📞 NUMERO TÉL",
"value" => "0745264451",
"dataDetectorTypes" => ["PKDataDetectorTypePhoneNumber"]
],
[
"key" => "back-planity",
"label" => "📅 PRISE DE RDV",
"value" => "https://www.planity.com/immy-beauty-93410-vaujours",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-instagram",
"label" => "📷 INSTAGRAM",
"value" => "@immybeauty.fr",
"link" => "https://www.instagram.com/immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-tiktok",
"label" => "🎵 TIKTOK",
"value" => "@immybeauty.fr",
"link" => "https://www.tiktok.com/@immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-email",
"label" => "✉️ EMAIL",
"value" => "contact@immybeauty.fr",
"link" => "mailto:contact@immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-website",
"label" => "🌐 SITE INTERNET",
"value" => "www.immybeauty.fr",
"link" => "https://immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
// [
// "key" => "hidden_update_trigger",
// "label" => "Dernière mise à jour",
// "value" => (new \DateTime())->format('Y-m-d H:i:s'),
// 'changeMessage' => '%@'
// ],
[
"key" => "notification_center",
"label" => "📬 Centre de messages",
"value" => "",
'changeMessage' => '%@',
'hidden' => true // Ajoutez cette ligne pour cacher le champ
],
],
]
];
$pass->setData($data);
$passContent = $pass->create();
$response = new Response($passContent);
$response->headers->set('Content-Type', 'application/vnd.apple.pkpass');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'pass.pkpass'
));
return $response;
} catch (AccessDeniedException $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_FORBIDDEN);
} catch (\Exception $e) {
return new JsonResponse([
'error' => 'Une erreur est survenue lors de la création du pass.',
'message' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route('/api/v1/passes/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}', name: 'register_device', methods: ['POST'])]
public function registerDevice(
string $deviceLibraryIdentifier,
string $passTypeIdentifier,
string $serialNumber,
Request $request,
EntityManagerInterface $entityManager
): JsonResponse
{
try {
$content = json_decode($request->getContent(), true);
$pushToken = $content['pushToken'] ?? null;
if (!$pushToken) {
return new JsonResponse(['error' => 'Push token is required'], Response::HTTP_BAD_REQUEST);
}
$user = $this->userRepository->findOneBy(['slug' => $serialNumber]);
if (!$user) {
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$card = $user->getCard();
if (!$card) {
return new JsonResponse(['error' => 'Card not found'], Response::HTTP_NOT_FOUND);
}
$card->setAppleWalletPushToken($pushToken);
$card->setDeviceLibraryIdentifier($deviceLibraryIdentifier);
$entityManager->persist($card);
$entityManager->flush();
return new JsonResponse(['status' => 'Push token registered successfully'], Response::HTTP_OK);
} catch (\Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route('/test-notification/{userSlug}/{notificationMessage}', name: 'test_notification', methods: ['GET'])]
public function testNotification(string $userSlug, string $notificationMessage): JsonResponse
{
$this->writeLog('Début de testNotification pour userSlug: ' . $userSlug);
$this->writeLog('Message de notification reçu: ' . $notificationMessage);
// Décoder le message de notification (il peut contenir des caractères spéciaux encodés dans l'URL)
$decodedMessage = urldecode($notificationMessage);
$request = new Request([], ['notificationMessage' => $decodedMessage]);
$this->writeLog('Appel de updatePass');
$result = $this->updatePass($userSlug, $request, $this->entityManager);
$resultData = json_decode($result->getContent(), true);
$this->writeLog('Nouvelle date de mise à jour : ' . ($resultData['lastUpdated'] ?? 'non disponible'));
$this->writeLog('Fin de testNotification. Résultat: ' . json_encode($result->getContent()));
return $result;
}
public function updatePass(string $userSlug, Request $request, EntityManagerInterface $entityManager): JsonResponse
{
$this->writeLog('Début de updatePass pour userSlug: ' . $userSlug);
try {
$user = $this->userRepository->findOneBy(['slug' => $userSlug]);
if (!$user) {
$this->writeLog('Utilisateur non trouvé pour le slug: ' . $userSlug);
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$card = $user->getCard();
if (!$card) {
$this->writeLog('Carte non trouvée pour l\'utilisateur: ' . $userSlug);
return new JsonResponse(['error' => 'Card not found'], Response::HTTP_NOT_FOUND);
}
$this->writeLog('Création du pass avec le certificat');
$certificatePath = $this->getParameter('pass_certificate_path');
$certificatePassword = $this->getParameter('pass_certificate_password');
$pass = new PKPass($certificatePath, $certificatePassword);
$this->writeLog('Génération des données du pass');
// $notificationMessage = $this->generateRandomMessage();
$notificationMessage = $request->request->get('notificationMessage');
$oldNotification = $card->getLastNotification();
$card->setLastNotification($notificationMessage);
$this->entityManager->persist($card);
$this->entityManager->flush();
$oldData = $this->generatePassData($user, $card, $oldNotification);
$newData = $this->generatePassData($user, $card, $notificationMessage);
$this->writeLog('Comparaison des anciennes et nouvelles données:');
$this->logDataDifferences($oldData, $newData);
$oldNotification = $oldData['storeCard']['backFields'][8]['value'] ?? '';
$newNotification = $newData['storeCard']['backFields'][8]['value'] ?? '';
$this->writeLog("Ancienne notification : " . $oldNotification);
$this->writeLog("Nouvelle notification : " . $newNotification);
if ($oldNotification !== $newNotification) {
$this->writeLog("Le message de notification a changé, mise à jour nécessaire");
} else {
$this->writeLog("ATTENTION: Le message de notification n'a pas changé");
}
$this->writeLog('Définition des données du pass');
$pass->setData($newData);
$this->writeLog('Ajout des fichiers au pass');
$this->addFilesToPass($pass);
$this->writeLog('Création du contenu du pass');
$newPassContent = $pass->create();
$this->writeLog('Comparaison du contenu du pass:');
$oldPassContent = $this->getExistingPassContent($user);
if ($oldPassContent === $newPassContent) {
$this->writeLog('ATTENTION: Le contenu du pass n\'a pas changé!');
} else {
$this->writeLog('Le contenu du pass a été modifié');
}
$this->writeLog('Sauvegarde du contenu du pass');
$this->savePassContent($user, $newPassContent);
// Mise à jour de la date de modification de la carte
$now = new \DateTime();
$card->setUpdatedAt($now);
$entityManager->persist($card);
$entityManager->flush();
$pushToken = $card->getAppleWalletPushToken();
if ($pushToken) {
$this->writeLog('Envoi de la notification push silencieuse');
$result = $this->sendSilentPushNotification($pushToken, $newData['passTypeIdentifier']);
$this->writeLog('Résultat de l\'envoi de la notification: ' . json_encode($result));
} else {
$this->writeLog('ATTENTION: Aucun pushToken trouvé pour l\'envoi de la notification');
}
$this->writeLog('Fin de updatePass avec succès');
return new JsonResponse([
'status' => 'Pass updated and notification sent',
'lastUpdated' => $now->format(DateTimeInterface::ISO8601)
], Response::HTTP_OK);
} catch (\Exception $e) {
$this->writeLog('Erreur dans updatePass: ' . $e->getMessage());
$this->writeLog('Trace complète: ' . $e->getTraceAsString());
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function logDataDifferences(array $oldData, array $newData): void
{
$differences = $this->arrayRecursiveDiff($oldData, $newData);
$this->writeLog('Différences dans les données: ' . json_encode($differences, JSON_PRETTY_PRINT));
}
private function arrayRecursiveDiff($arr1, $arr2) {
$result = array();
foreach ($arr1 as $key => $value) {
if (array_key_exists($key, $arr2)) {
if (is_array($value)) {
$recursiveDiff = $this->arrayRecursiveDiff($value, $arr2[$key]);
if (count($recursiveDiff)) {
$result[$key] = $recursiveDiff;
}
} else {
if ($value != $arr2[$key]) {
$result[$key] = array('old' => $value, 'new' => $arr2[$key]);
}
}
} else {
$result[$key] = array('old' => $value, 'new' => 'N/A');
}
}
return $result;
}
private function generatePassData(User $user, Card $card, string $notificationMessage): array
{
$dernierPassage = $this->cardTransactionRepository->getDernierPassage($user)?->getTransactionDate();
$lastTransaction = $card->getLastTransactionDate();
$lastTransactionFormatted = $lastTransaction ? $lastTransaction->format('d/m/Y') : 'Aucun passage';
$styleUpdateMessage = "Style mis à jour le " . (new \DateTime())->format('d/m/Y H:i');
// $newColors = [
// $this->getRandomColor(),
// $this->getRandomColor(),
// $this->getRandomColor()
// ];
//
// $colorUpdateMessage = "Couleurs mises à jour (" . implode(', ', $newColors) . ")";
return [
'formatVersion' => 1,
'passTypeIdentifier' => 'pass.com.immybeauty.pkpass',
'teamIdentifier' => 'V9N5857WGW',
'serialNumber' => $user->getSlug(),
'webServiceURL' => 'https://immybeauty.fr/api/v1/passes',
'authenticationToken' => $card->getAppleWalletToken(),
'pushToken' => '', // Ce champ sera rempli par Apple lors de l'enregistrement du pass
'allowsUpdates' => true,
'sharingProhibited' => false,
"barcodes" => [
[
"message" => $this->generateQRCodeURLProfil($user),
"format" => "PKBarcodeFormatQR",
"messageEncoding" => "iso-8859-1"
]
],
"organizationName" => "IMMY BEAUTY",
"foregroundColor" => "rgb(255, 255, 255)", // BLANC
"backgroundColor" => "rgb(20, 20, 20)", // NOIR
"labelColor" => "rgb(174, 136, 90)", // DORÉ
// "foregroundColor" => $newColors[0],
// "backgroundColor" => $newColors[1],
// "labelColor" => $newColors[2],
"description" => "IMMY BEAUTY",
"storeCard" => [
// 'primaryFields' => [
// [
// 'key' => 'notification',
// 'label' => 'Notification',
// 'value' => $notificationMessage,
// 'changeMessage' => 'Nouvelle notification : %@'
// ]
// ],
// [
// 'key' => 'style_update',
// 'label' => 'Mise à jour du style',
// 'value' => $styleUpdateMessage,
// 'changeMessage' => 'Nouveau style appliqué : %@'
// ],
'headerFields' => [
[
'key' => 'header-name',
'textAlignment' => 'PKTextAlignmentRight',
'label' => 'CLIENT',
'value' => $user->getFullName(),
'changeMessage' => 'Nom du client mis à jour : %@'
]
],
'auxiliaryFields' => [
// [
// 'key' => 'color_update',
// 'label' => 'MISE À JOUR',
// 'value' => $colorUpdateMessage,
// 'changeMessage' => 'Nouvelles couleurs appliquées : %@'
// ],
[
'key' => 'dernier-passage',
'label' => 'DERNIER PASSAGE',
'value' => $dernierPassage ? $dernierPassage->format('d/m/Y') : '-',
'textAlignment' => 'PKTextAlignmentLeft',
'changeMessage' => 'Nouveau dernier passage : %@'
],
[
'key' => 'points',
'label' => 'POINTS',
'value' => $card->getPoints(),
'textAlignment' => 'PKTextAlignmentRight',
'changeMessage' => 'Nouveau solde de points : %@'
]
],
"backFields" => [
[
"key" => "back-name",
"label" => "SALON",
"value" => "IMMY BEAUTY"
],
[
"key" => "back-horaires",
"label" => "🕒 HORAIRES",
"value" => "Mar - Dim\n10h00 - 19h30"
],
[
"key" => "back-tel",
"label" => "📞 NUMERO TÉL",
"value" => "0745264451",
"dataDetectorTypes" => ["PKDataDetectorTypePhoneNumber"]
],
[
"key" => "back-planity",
"label" => "📅 PRISE DE RDV",
"value" => "https://www.planity.com/immy-beauty-93410-vaujours",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-instagram",
"label" => "📷 INSTAGRAM",
"value" => "@immybeauty.fr",
"link" => "https://www.instagram.com/immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-tiktok",
"label" => "🎵 TIKTOK",
"value" => "@immybeauty.fr",
"link" => "https://www.tiktok.com/@immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-email",
"label" => "✉️ EMAIL",
"value" => "contact@immybeauty.fr",
"link" => "mailto:contact@immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
[
"key" => "back-website",
"label" => "🌐 SITE INTERNET",
"value" => "www.immybeauty.fr",
"link" => "https://immybeauty.fr",
"dataDetectorTypes" => ["PKDataDetectorTypeLink"]
],
// [
// "key" => "hidden_update_trigger",
// "label" => "Dernière mise à jour",
// "value" => (new \DateTime())->format('Y-m-d H:i:s'),
// 'changeMessage' => '%@'
// ],
[
"key" => "notification_center",
"label" => "📬 Centre de messages",
"value" => $notificationMessage,
// "value" => "PUTIN DE MERDE " . (new \DateTime())->format('Y-m-d H:i:s'),
"changeMessage" => "%@",
'hidden' => true // Ajoutez cette ligne pour cacher le champ
],
],
]
];
}
private function generateRandomMessage(): string
{
$messages = [
"QUEL BAIL VOUS ?",
"QUOI DE NEUF ?",
"ÇA ROULE ?",
"COMMENT ÇA VA ?",
"QUOI DE BON ?",
"QUELLES NOUVELLES ?",
"ON DIT QUOI ?",
"ALORS, CONTENT(E) ?",
"TOUT BAIGNE ?",
"LA FORME ?"
];
return $messages[array_rand($messages)];
}
private function sendSilentPushNotification(string $pushToken, string $passTypeIdentifier): array
{
$url = "https://api.push.apple.com/3/device/$pushToken";
$this->writeLog("Début de sendSilentPushNotification");
$this->writeLog("PushToken: $pushToken");
$this->writeLog("PassTypeIdentifier: $passTypeIdentifier");
try {
$jwt = $this->getJWT();
$this->writeLog("JWT généré avec succès. Longueur du JWT: " . strlen($jwt));
// Payload vide pour une notification silencieuse
$payload = [
'aps' => [
'content-available' => 1
]
];
$this->writeLog("Payload de la notification: " . json_encode($payload));
$headers = [
'apns-topic' => $passTypeIdentifier,
'apns-push-type' => 'background',
'authorization' => 'bearer ' . $jwt,
];
$this->writeLog("Headers de la requête: " . json_encode($headers));
$response = $this->httpClient->request('POST', $url, [
'headers' => $headers,
'json' => $payload,
'http_version' => '2.0',
]);
$statusCode = $response->getStatusCode();
$responseContent = $response->getContent(false);
$responseHeaders = $response->getHeaders();
$this->writeLog("Réponse reçue. Statut: $statusCode");
$this->writeLog("Contenu de la réponse: $responseContent");
$this->writeLog("Headers de la réponse: " . json_encode($responseHeaders));
if ($statusCode !== 200) {
$this->logger->error('Erreur lors de l\'envoi de la notification push', [
'status_code' => $statusCode,
'response' => $responseContent,
'headers' => $responseHeaders,
]);
return ['success' => false, 'status' => $statusCode, 'message' => $responseContent];
} else {
$this->writeLog("Notification push silencieuse envoyée avec succès");
return ['success' => true, 'status' => $statusCode];
}
} catch (\Exception $e) {
$this->writeLog("Exception lors de l'envoi de la notification push: " . $e->getMessage());
$this->writeLog("Trace complète: " . $e->getTraceAsString());
$this->logger->error('Exception lors de l\'envoi de la notification push', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return ['success' => false, 'status' => 500, 'message' => $e->getMessage()];
}
}
private function getJWT(): string
{
$privateKeyPath = $this->getParameter('apple_push_private_key_path');
$privateKey = file_get_contents($privateKeyPath);
$keyId = $this->getParameter('apple_push_key_id');
$teamId = $this->getParameter('apple_team_id');
$payload = [
'iss' => $teamId,
'iat' => time(),
'exp' => time() + 3600, // Expire dans 1 heure
];
return JWT::encode($payload, $privateKey, 'ES256', $keyId);
}
private function savePassContent(User $user, string $passContent): void
{
$passDirectory = $this->getParameter('kernel.project_dir') . '/var/passes';
$filename = sprintf('%s/%s.pkpass', $passDirectory, $user->getSlug());
try {
$this->fileSystem->dumpFile($filename, $passContent);
} catch (\Exception $e) {
$this->logger->error('Erreur lors de la sauvegarde du contenu du pass', [
'user' => $user->getId(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* @throws \Exception
*/
#[Route('/api/v1/passes/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}', name: 'check_updates', methods: ['GET'])]
public function checkUpdates(string $deviceLibraryIdentifier, string $passTypeIdentifier, Request $request): JsonResponse
{
$this->writeLog("Début de checkUpdates pour device: $deviceLibraryIdentifier, passType: $passTypeIdentifier");
$this->writeLog("Headers de la requête: " . json_encode($request->headers->all()));
try {
// // Vérification de l'authentification
// $authenticationToken = $request->headers->get('Authorization');
// $this->writeLog("Token d'authentification reçu: " . ($authenticationToken ? substr($authenticationToken, 0, 10) . '...' : 'non défini'));
// if (!$this->isValidAuthenticationToken($authenticationToken)) {
// $this->writeLog("Authentification invalide pour checkUpdates");
// return new JsonResponse(['error' => 'Invalid authentication token'], Response::HTTP_UNAUTHORIZED);
// }
// Récupération du paramètre passesUpdatedSince
$passesUpdatedSince = $request->query->get('passesUpdatedSince');
$this->writeLog("passesUpdatedSince reçu: " . ($passesUpdatedSince ?? 'non défini'));
try {
// Supprimer l'espace et le '00:00' à la fin si présent
$passesUpdatedSince = preg_replace('/\s+00:00$/', '', $passesUpdatedSince);
$updatedSince = $passesUpdatedSince ? new \DateTime($passesUpdatedSince) : null;
$this->writeLog("Date parsée avec succès: " . ($updatedSince ? $updatedSince->format(\DateTime::ATOM) : 'null'));
} catch (\Exception $e) {
$this->writeLog("Erreur de parsing de la date: " . $e->getMessage());
$updatedSince = null;
}
// Récupération des cartes mises à jour
$updatedCards = $this->cardRepository->findUpdatedCards($deviceLibraryIdentifier, $updatedSince);
$this->writeLog("Nombre de cartes trouvées pour ce device: " . count($updatedCards));
$serialNumbers = [];
$lastUpdated = null;
foreach ($updatedCards as $card) {
$this->writeLog("Carte ID: " . $card->getId() . ", UpdatedAt: " . $card->getUpdatedAt()->format(\DateTime::ATOM));
$serialNumbers[] = $card->getUser()->getSlug();
if ($lastUpdated === null || $card->getUpdatedAt() > $lastUpdated) {
$lastUpdated = $card->getUpdatedAt();
}
}
$this->writeLog("Nombre de passes à mettre à jour : " . count($serialNumbers));
$response = [
'serialNumbers' => $serialNumbers,
'lastUpdated' => $lastUpdated ? $lastUpdated->format(\DateTime::ATOM) : null
];
$this->writeLog("Fin de checkUpdates. Réponse : " . json_encode($response));
return new JsonResponse($response);
} catch (\Exception $e) {
$this->writeLog("Erreur dans checkUpdates: " . $e->getMessage());
$this->writeLog("Trace complète : " . $e->getTraceAsString());
return new JsonResponse(['error' => 'An error occurred during update check'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function isValidAuthenticationToken(?string $token): bool
{
// Implémentez ici la logique de vérification du token
// Pour le test, vous pouvez simplement retourner true
return true;
}
/**
* @throws PKPassException
*/
#[Route('/api/v1/passes/{passTypeIdentifier}/{serialNumber}', name: 'get_updated_pass', methods: ['GET'])]
public function getUpdatedPass(
string $passTypeIdentifier,
string $serialNumber,
Request $request
): Response
{
// Vérification de l'authentification
$authenticationToken = $request->headers->get('Authorization');
if (!$this->isValidAuthenticationToken($authenticationToken)) {
return new JsonResponse(['error' => 'Invalid authentication token'], Response::HTTP_UNAUTHORIZED);
}
// Trouver l'utilisateur correspondant au serialNumber
$user = $this->userRepository->findOneBy(['slug' => $serialNumber]);
if (!$user) {
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$card = $user->getCard();
if (!$card) {
return new JsonResponse(['error' => 'Card not found'], Response::HTTP_NOT_FOUND);
}
// Vérifier si le passTypeIdentifier correspond
if ($passTypeIdentifier !== 'pass.com.immybeauty.pkpass') {
return new JsonResponse(['error' => 'Invalid pass type identifier'], Response::HTTP_BAD_REQUEST);
}
// Générer le contenu mis à jour du pass
$certificatePath = $this->getParameter('pass_certificate_path');
$certificatePassword = $this->getParameter('pass_certificate_password');
$pass = new PKPass($certificatePath, $certificatePassword);
$data = $this->generatePassData($user, $card, '');
$pass->setData($data);
// Ajouter les fichiers nécessaires au pass (images, etc.)
$this->addFilesToPass($pass);
// Créer le contenu du pass
$passContent = $pass->create();
// Envoyer le contenu du pass comme réponse
$response = new Response($passContent);
$response->headers->set('Content-Type', 'application/vnd.apple.pkpass');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'updated_pass.pkpass'
));
return $response;
}
/**
* @throws PKPassException
*/
private function addFilesToPass(PKPass $pass): void
{
$imagesDir = $this->getParameter('kernel.project_dir') . '/public/images/apple-wallet/';
$requiredImages = ['icon.png', 'icon@2x.png', 'icon@3x.png', 'logo.png', 'logo@2x.png', 'logo@3x.png', 'strip.png', 'strip@2x.png', 'strip@3x.png'];
foreach ($requiredImages as $image) {
$imagePath = $imagesDir . $image;
if (file_exists($imagePath)) {
try {
$pass->addFile($imagePath);
} catch (\Exception $e) {
throw $e;
}
} else {
throw new \Exception("Required image file not found: $image");
}
}
}
private function writeLog(string $message): void
{
$logFile = $this->getParameter('kernel.project_dir') . '/apple_wallet.log';
$timestamp = date('Y-m-d H:i:s');
$logMessage = "[$timestamp] $message" . PHP_EOL;
file_put_contents($logFile, $logMessage, FILE_APPEND);
}
#[Route('/api/v1/passes/v1/passes/{passTypeIdentifier}/{serialNumber}', name: 'get_pass', methods: ['GET'])]
public function getPass(string $passTypeIdentifier, string $serialNumber, Request $request): Response
{
$this->writeLog("Début de getPass pour passTypeIdentifier: $passTypeIdentifier, serialNumber: $serialNumber");
$this->writeLog("Headers de la requête: " . json_encode($request->headers->all()));
// Vérification de l'authentification
$authenticationToken = $request->headers->get('Authorization');
$this->writeLog("Token d'authentification reçu: " . ($authenticationToken ? substr($authenticationToken, 0, 10) . '...' : 'non défini'));
if (!$this->isValidAuthenticationToken($authenticationToken)) {
$this->writeLog("Authentification invalide pour getPass");
return new JsonResponse(['error' => 'Invalid authentication token'], Response::HTTP_UNAUTHORIZED);
}
// Vérifier si le passTypeIdentifier correspond
if ($passTypeIdentifier !== 'pass.com.immybeauty.pkpass') {
$this->writeLog("PassTypeIdentifier invalide: $passTypeIdentifier");
return new JsonResponse(['error' => 'Invalid pass type identifier'], Response::HTTP_BAD_REQUEST);
}
// Trouver l'utilisateur correspondant au serialNumber
$user = $this->userRepository->findOneBy(['slug' => $serialNumber]);
if (!$user) {
$this->writeLog("Utilisateur non trouvé pour serialNumber: $serialNumber");
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$this->writeLog("Utilisateur trouvé: ID=" . $user->getId() . ", Nom=" . $user->getFullName());
$card = $user->getCard();
if (!$card) {
$this->writeLog("Carte non trouvée pour l'utilisateur: " . $user->getId());
return new JsonResponse(['error' => 'Card not found'], Response::HTTP_NOT_FOUND);
}
$this->writeLog("Carte trouvée: ID=" . $card->getId() . ", Points=" . $card->getPoints());
// Vérifier si le pass a été modifié depuis la dernière requête
$ifModifiedSince = $request->headers->get('If-Modified-Since');
$lastModified = $card->getUpdatedAt();
if ($ifModifiedSince && $lastModified) {
$ifModifiedSinceDate = \DateTime::createFromFormat(\DateTime::RFC7231, $ifModifiedSince);
$lastModifiedFormatted = $lastModified->format(\DateTime::RFC7231);
$this->writeLog("If-Modified-Since: " . $ifModifiedSince);
$this->writeLog("Last-Modified: " . $lastModifiedFormatted);
if ($ifModifiedSinceDate && $lastModified <= $ifModifiedSinceDate) {
$this->writeLog("Pass non modifié, renvoi 304 Not Modified");
return new Response(null, Response::HTTP_NOT_MODIFIED);
}
}
$this->writeLog("Pass modifié depuis la dernière requête ou première requête");
try {
$this->writeLog("Génération du pass pour l'utilisateur: " . $user->getId());
$certificatePath = $this->getParameter('pass_certificate_path');
$certificatePassword = $this->getParameter('pass_certificate_password');
$this->writeLog("Chemin du certificat: $certificatePath");
$pass = new PKPass($certificatePath, $certificatePassword);
$lastNotification = $card->getLastNotification() ?? '';
$data = $this->generatePassData($user, $card, $lastNotification);
$this->writeLog("Données du pass générées: " . json_encode($data));
$pass->setData($data);
$this->writeLog("Début de l'ajout des fichiers au pass");
$this->addFilesToPass($pass);
$this->writeLog("Fin de l'ajout des fichiers au pass");
$this->writeLog("Début de la création du contenu du pass");
$newPassContent = $pass->create();
$this->writeLog("Fin de la création du contenu du pass");
if ($newPassContent === null) {
$this->writeLog("Erreur : Impossible de créer le contenu du nouveau pass");
return new JsonResponse(['error' => 'Failed to create pass content'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
$this->writeLog("Nouveau pass créé avec succès. Taille du contenu : " . strlen($newPassContent) . " octets");
// Comparaison avec l'ancien contenu du pass
$oldPassContent = $this->getExistingPassContent($user);
if ($oldPassContent === $newPassContent) {
$this->writeLog("ATTENTION: Le contenu du pass n'a pas changé!");
} else {
$this->writeLog("Le contenu du pass a été modifié");
$this->logPassDifferences($oldPassContent, $newPassContent);
}
// Mettre à jour le contenu du pass et la date de modification
$card->setUpdatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
$this->entityManager->flush();
$this->writeLog("Pass mis à jour dans la base de données avec la nouvelle date : " . $lastModified->format(\DateTime::RFC7231));
$response = new Response($newPassContent);
$response->headers->set('Last-Modified', $lastModified->format(\DateTime::RFC7231));
$response->headers->set('Content-Type', 'application/vnd.apple.pkpass');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'updated_pass.pkpass'
));
$this->writeLog("Pass généré et envoyé avec Last-Modified: " . $lastModified->format(\DateTime::RFC7231));
$this->writeLog("Taille du contenu envoyé : " . strlen($newPassContent) . " octets");
$this->writeLog("Headers de la réponse : " . json_encode($response->headers->all()));
return $response;
} catch (\Exception $e) {
$this->writeLog("Erreur lors de la génération du pass: " . $e->getMessage());
$this->writeLog("Trace complète : " . $e->getTraceAsString());
return new JsonResponse(['error' => 'An error occurred during pass generation: ' . $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function logPassDifferences($oldContent, $newContent): void
{
$this->writeLog("Début de la comparaison des passes");
$this->writeLog("Taille de l'ancien contenu : " . strlen($oldContent) . " octets");
$this->writeLog("Taille du nouveau contenu : " . strlen($newContent) . " octets");
// Comparer les 100 premiers octets
$this->writeLog("Premiers 100 octets de l'ancien contenu : " . bin2hex(substr($oldContent, 0, 100)));
$this->writeLog("Premiers 100 octets du nouveau contenu : " . bin2hex(substr($newContent, 0, 100)));
// Comparer les 100 derniers octets
$this->writeLog("Derniers 100 octets de l'ancien contenu : " . bin2hex(substr($oldContent, -100)));
$this->writeLog("Derniers 100 octets du nouveau contenu : " . bin2hex(substr($newContent, -100)));
// Comparer les métadonnées du pass
$oldMetadata = $this->extractPassMetadata($oldContent);
$newMetadata = $this->extractPassMetadata($newContent);
$this->writeLog("Anciennes métadonnées : " . json_encode($oldMetadata, JSON_PRETTY_PRINT));
$this->writeLog("Nouvelles métadonnées : " . json_encode($newMetadata, JSON_PRETTY_PRINT));
$diff = $this->arrayRecursiveDiff($oldMetadata, $newMetadata);
if (!empty($diff)) {
$this->writeLog("Différences dans les métadonnées : " . json_encode($diff, JSON_PRETTY_PRINT));
} else {
$this->writeLog("Aucune différence dans les métadonnées");
}
$this->writeLog("Fin de la comparaison des passes");
}
private function extractPassMetadata($content): array
{
$metadata = [];
$tempFile = tempnam(sys_get_temp_dir(), 'pass_');
if ($tempFile === false) {
$this->writeLog("Erreur : Impossible de créer un fichier temporaire");
return $metadata;
}
try {
file_put_contents($tempFile, $content);
$zip = new \ZipArchive();
if ($zip->open($tempFile) === TRUE) {
if (($jsonData = $zip->getFromName('pass.json')) !== false) {
$metadata = json_decode($jsonData, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->writeLog("Erreur de décodage JSON : " . json_last_error_msg());
}
} else {
$this->writeLog("Fichier pass.json non trouvé dans l'archive");
}
$zip->close();
} else {
$this->writeLog("Impossible d'ouvrir l'archive ZIP");
}
} catch (\Exception $e) {
$this->writeLog("Erreur lors de l'extraction des métadonnées : " . $e->getMessage());
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
return $metadata;
}
private function getExistingPassContent(User $user): ?string
{
$passPath = $this->getParameter('kernel.project_dir') . '/var/passes/' . $user->getSlug() . '.pkpass';
if (file_exists($passPath)) {
$content = file_get_contents($passPath);
return $content !== false ? $content : null;
}
$this->writeLog("Pas de pass existant trouvé pour l'utilisateur: " . $user->getId());
return null;
}
#[Route('/api/v1/passes/v1/log', name: 'pass_log', methods: ['POST'])]
public function logPass(Request $request): JsonResponse
{
$content = $request->getContent();
$this->writeLog('Pass log received: ' . $content);
return new JsonResponse(null, Response::HTTP_OK);
}
#[Route('/api/v1/passes/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}', name: 'unregister_device', methods: ['DELETE'])]
public function unregisterDevice(
string $deviceLibraryIdentifier,
string $passTypeIdentifier,
string $serialNumber,
Request $request,
EntityManagerInterface $entityManager
): JsonResponse
{
$this->writeLog("Début de unregisterDevice pour device: $deviceLibraryIdentifier, passType: $passTypeIdentifier, serialNumber: $serialNumber");
// Vérification de l'authentification
$authToken = $request->headers->get('Authorization');
if (!$this->isValidAuthenticationToken($authToken)) {
$this->writeLog("Authentification invalide pour unregisterDevice");
return new JsonResponse(['error' => 'Invalid authentication token'], Response::HTTP_UNAUTHORIZED);
}
try {
// Trouver l'utilisateur correspondant au serialNumber
$user = $this->userRepository->findOneBy(['slug' => $serialNumber]);
if (!$user) {
$this->writeLog("Utilisateur non trouvé pour serialNumber: $serialNumber");
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
$card = $user->getCard();
if (!$card) {
$this->writeLog("Carte non trouvée pour l'utilisateur: " . $user->getId());
return new JsonResponse(['error' => 'Card not found'], Response::HTTP_NOT_FOUND);
}
// Vérifier si le deviceLibraryIdentifier correspond
if ($card->getDeviceLibraryIdentifier() !== $deviceLibraryIdentifier) {
$this->writeLog("DeviceLibraryIdentifier ne correspond pas pour l'utilisateur: " . $user->getId());
return new JsonResponse(['error' => 'Device mismatch'], Response::HTTP_BAD_REQUEST);
}
// Supprimer les informations d'enregistrement
$card->setAppleWalletToken(null);
$card->setAppleWalletPushToken(null);
$card->setDeviceLibraryIdentifier(null);
// Mettre à jour la date de modification
$card->setUpdatedAt(new \DateTime());
$entityManager->persist($card);
$entityManager->flush();
$this->writeLog("Désinscription réussie pour l'utilisateur: " . $user->getId());
return new JsonResponse(null, Response::HTTP_OK);
} catch (\Exception $e) {
$this->writeLog("Erreur lors de la désinscription: " . $e->getMessage());
return new JsonResponse(['error' => 'An error occurred during unregistration'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* @throws PKPassException
*/
#[Route('/test-apple-wallet-flow/{userSlug}', name: 'test_apple_wallet_flow')]
public function testAppleWalletFlow(string $userSlug): JsonResponse
{
$this->log('Début du test du flux Apple Wallet', ['userSlug' => $userSlug]);
// Créer une requête avec un jeton CSRF valide
$csrfToken = $this->csrfTokenManager->getToken('add-to-apple-wallet');
$request = new Request(['_token' => $csrfToken->getValue()]);
// Simuler l'ajout d'un pass
$addResponse = $this->addToAppleWallet($userSlug, $request);
$this->log('Réponse de addToAppleWallet', ['status' => $addResponse->getStatusCode()]);
// Simuler l'enregistrement d'un appareil
$registerRequest = new Request([], [], [], [], [], [], json_encode(['pushToken' => 'test_push_token']));
$registerResponse = $this->registerDevice('test_device', 'pass.com.immybeauty.pkpass', $userSlug, $registerRequest, $this->entityManager);
$this->log('Réponse de registerDevice', ['status' => $registerResponse->getStatusCode()]);
// Simuler une vérification des mises à jour
$checkUpdatesResponse = $this->checkUpdates('test_device', 'pass.com.immybeauty.pkpass', new Request());
$this->log('Réponse de checkUpdates', ['status' => $checkUpdatesResponse->getStatusCode(), 'content' => $checkUpdatesResponse->getContent()]);
// Simuler une mise à jour du pass
$updateResponse = $this->updatePass($userSlug, new Request(), $this->entityManager);
$this->log('Réponse de updatePass', ['status' => $updateResponse->getStatusCode(), 'content' => $updateResponse->getContent()]);
// Simuler la récupération d'un pass mis à jour
$getPassResponse = $this->getPass('pass.com.immybeauty.pkpass', $userSlug, new Request());
$this->log('Réponse de getPass', ['status' => $getPassResponse->getStatusCode()]);
// Simuler la désinscription d'un appareil
$unregisterResponse = $this->unregisterDevice('test_device', 'pass.com.immybeauty.pkpass', $userSlug, new Request(), $this->entityManager);
$this->log('Réponse de unregisterDevice', ['status' => $unregisterResponse->getStatusCode()]);
$this->log('Fin du test du flux Apple Wallet');
return new JsonResponse(['message' => 'Test du flux Apple Wallet terminé'], Response::HTTP_OK);
}
private function log(string $message, array $context = []): void
{
$this->logger->info($message, $context);
$this->writeLog($message . ' ' . json_encode($context));
}
private function generateQRCodeURLProfil(User $user): string
{
return $this->getParameter('APP_URL') . '/fidelite/mon-profil/' . $user->getSlug();
}
}