Files
yogibook_aury_new/public/class/push-functions.php
T
2026-08-15 12:00:28 +03:00

375 lines
13 KiB
PHP

<?php
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
use Dotenv\Dotenv;
date_default_timezone_set('Europe/Rome');
class PushSender
{
/** @var string */
private $projectId;
/**
* @var string Endpoint di Google. Si puo' sovrascrivere con FCM_OAUTH_URL /
* FCM_BASE_URL nel .env: serve SOLO per i test locali, che puntano a un
* finto server FCM. In produzione queste variabili non vanno impostate.
*/
private $oauthUrl;
/** @var string */
private $baseUrl;
/** @var array Contenuto del service account JSON */
private $credentials;
/** @var string|null Token OAuth2 in cache per la durata dello script */
private $accessToken = null;
/** @var int Timestamp di scadenza del token in cache */
private $accessTokenExpiry = 0;
/**
* @var string|null Se l'autenticazione con Google fallisce, il motivo resta qui:
* gli invii successivi escono subito invece di ritentare per ogni utente.
*/
private $authFallita = null;
public function __construct()
{
$rootPath = dirname(__DIR__, 2);
// Le variabili possono essere gia' state caricate da un altro require:
// safeLoad non esplode se il .env manca o e' gia' in memoria.
$dotenv = Dotenv::createImmutable($rootPath);
$dotenv->safeLoad();
$this->projectId = $_ENV['FCM_PROJECT_ID'] ?? '';
$credentialsPath = $_ENV['FCM_CREDENTIALS_PATH'] ?? '';
$this->oauthUrl = $_ENV['FCM_OAUTH_URL'] ?? 'https://oauth2.googleapis.com/token';
$this->baseUrl = $_ENV['FCM_BASE_URL'] ?? 'https://fcm.googleapis.com';
if ($this->projectId === '' || $credentialsPath === '') {
throw new RuntimeException(
'FCM_PROJECT_ID o FCM_CREDENTIALS_PATH mancanti nel file .env'
);
}
// Percorso relativo -> lo consideriamo a partire dalla root del progetto.
if ($credentialsPath[0] !== '/') {
$credentialsPath = $rootPath . '/' . $credentialsPath;
}
if (!is_readable($credentialsPath)) {
throw new RuntimeException("Service account non leggibile: $credentialsPath");
}
$json = json_decode((string) file_get_contents($credentialsPath), true);
if (!is_array($json) || empty($json['client_email']) || empty($json['private_key'])) {
throw new RuntimeException("Service account non valido: $credentialsPath");
}
$this->credentials = $json;
}
/**
* Invia la notifica a tutti i dispositivi attivi dell'utente.
* I token rifiutati da FCM vengono disattivati (active = 'N').
*
* @return array ['sent' => int, 'failed' => int, 'errors' => string[], 'no_devices' => bool]
* no_devices = l'utente non ha dispositivi registrati: non e'
* un errore, semplicemente non ha (ancora) aperto l'app.
*/
public function sendToUser(PDO $db, int $iduser, string $title, string $body, array $data = []): array
{
$stmt = $db->prepare("
SELECT iduserdevicetoken, devicetoken
FROM userdevicetoken
WHERE iduser = :uid AND active = 'Y'
");
$stmt->execute([':uid' => $iduser]);
$devices = $stmt->fetchAll(PDO::FETCH_ASSOC);
$esito = ['sent' => 0, 'failed' => 0, 'errors' => [], 'no_devices' => !$devices];
foreach ($devices as $device) {
$risultato = $this->sendToToken($device['devicetoken'], $title, $body, $data);
if ($risultato['ok']) {
$esito['sent']++;
continue;
}
$esito['failed']++;
$esito['errors'][] = $risultato['error'];
// Token non piu' valido (app disinstallata, token rigenerato):
// lo spegniamo per non riprovarci ad ogni giro di cron.
if ($risultato['invalid_token']) {
$upd = $db->prepare("
UPDATE userdevicetoken SET active = 'N' WHERE iduserdevicetoken = :id
");
$upd->execute([':id' => $device['iduserdevicetoken']]);
}
}
return $esito;
}
/**
* Invia la notifica a un singolo token.
*
* @return array ['ok' => bool, 'error' => string, 'invalid_token' => bool]
*/
public function sendToToken(string $deviceToken, string $title, string $body, array $data = []): array
{
// Autenticazione gia' fallita in questo giro: inutile riprovare per ogni
// singolo utente, il cron deve solo finire in fretta e scrivere il log.
if ($this->authFallita !== null) {
return ['ok' => false, 'error' => $this->authFallita, 'invalid_token' => false];
}
try {
$accessToken = $this->getAccessToken();
} catch (Throwable $e) {
// Un problema di credenziali o di rete non deve interrompere il cron:
// lo registriamo e le notifiche restanti risulteranno "failed".
$this->authFallita = 'auth: ' . $e->getMessage();
return ['ok' => false, 'error' => $this->authFallita, 'invalid_token' => false];
}
$url = "{$this->baseUrl}/v1/projects/{$this->projectId}/messages:send";
// FCM accetta solo stringhe dentro "data".
$payloadData = [];
foreach ($data as $chiave => $valore) {
$payloadData[(string) $chiave] = (string) $valore;
}
$messaggio = [
'message' => [
'token' => $deviceToken,
'notification' => [
'title' => $title,
'body' => $body,
],
// Attenzione: un array PHP vuoto diventa [] in JSON, ma FCM si
// aspetta un oggetto e risponde 400. Se non ci sono dati da
// mandare, il campo va proprio omesso.
'data' => (object) $payloadData,
'android' => ['priority' => 'high'],
'apns' => [
'headers' => ['apns-priority' => '10'],
'payload' => ['aps' => ['sound' => 'default']],
],
],
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json; charset=UTF-8',
],
CURLOPT_POSTFIELDS => json_encode($messaggio, JSON_UNESCAPED_UNICODE),
]);
$risposta = curl_exec($ch);
$codice = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errCurl = curl_error($ch);
curl_close($ch);
if ($risposta === false) {
return ['ok' => false, 'error' => "curl: $errCurl", 'invalid_token' => false];
}
if ($codice >= 200 && $codice < 300) {
return ['ok' => true, 'error' => '', 'invalid_token' => false];
}
$decodificata = json_decode((string) $risposta, true);
$statoErrore = $decodificata['error']['status'] ?? '';
// UNREGISTERED / NOT_FOUND = app disinstallata o token non piu' valido.
//
// INVALID_ARGUMENT NON va messo qui: quasi sempre vuol dire che siamo
// noi a mandare un messaggio malformato, e spegnere i dispositivi degli
// utenti per un nostro errore li lascerebbe senza notifiche per sempre.
$tokenNonValido = in_array($statoErrore, ['UNREGISTERED', 'NOT_FOUND'], true);
return [
'ok' => false,
'error' => "HTTP $codice: " . substr((string) $risposta, 0, 300),
'invalid_token' => $tokenNonValido,
];
}
/**
* Token OAuth2 per FCM: JWT firmato con la chiave del service account,
* scambiato con un access token da Google. Vale un'ora, lo teniamo in cache.
*/
private function getAccessToken(): string
{
if ($this->accessToken !== null && time() < $this->accessTokenExpiry - 60) {
return $this->accessToken;
}
$adesso = time();
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
$claim = [
'iss' => $this->credentials['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $adesso,
'exp' => $adesso + 3600,
];
$daFirmare = $this->base64UrlEncode(json_encode($header))
. '.' . $this->base64UrlEncode(json_encode($claim));
$firma = '';
if (!openssl_sign($daFirmare, $firma, $this->credentials['private_key'], OPENSSL_ALGO_SHA256)) {
throw new RuntimeException('Firma del JWT fallita (chiave del service account non valida?)');
}
$jwt = $daFirmare . '.' . $this->base64UrlEncode($firma);
$ch = curl_init($this->oauthUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
]);
$risposta = curl_exec($ch);
$codice = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decodificata = json_decode((string) $risposta, true);
if ($codice !== 200 || empty($decodificata['access_token'])) {
throw new RuntimeException(
'Impossibile ottenere il token OAuth2 da Google: ' . substr((string) $risposta, 0, 300)
);
}
$this->accessToken = $decodificata['access_token'];
$this->accessTokenExpiry = $adesso + (int) ($decodificata['expires_in'] ?? 3600);
return $this->accessToken;
}
private function base64UrlEncode(string $dati): string
{
return rtrim(strtr(base64_encode($dati), '+/', '-_'), '=');
}
}
/**
* Sostituisce i segnaposto nei testi presi da notificationsetting.
* Esempio: pushRender('Ciao {first_name}', ['first_name' => 'Anna'])
*/
function pushRender(string $testo, array $valori): string
{
foreach ($valori as $chiave => $valore) {
$testo = str_replace('{' . $chiave . '}', (string) $valore, $testo);
}
return $testo;
}
/**
* Legge le impostazioni di un tipo di notifica (lesson / order / certificate).
* Ritorna solo le righe attive, ordinate dal preavviso piu' lungo.
*/
function pushSettings(PDO $db, string $tipo): array
{
$stmt = $db->prepare("
SELECT idnotificationsetting, notificationtype, daysbefore, sendhour,
title, title_multi, body, body_multi
FROM notificationsetting
WHERE notificationtype = :tipo AND active = 'Y'
ORDER BY daysbefore DESC
");
$stmt->execute([':tipo' => $tipo]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Verifica se una notifica e' gia' stata inviata (chiave anti-duplicato).
*
* Contano solo gli invii andati a buon fine: se il tentativo precedente era
* fallito (Firebase irraggiungibile, credenziali sbagliate) il giro successivo
* del cron deve riprovare.
*/
function pushGiaInviata(PDO $db, int $iduser, string $tipo, int $idreference, int $daysbefore): bool
{
$stmt = $db->prepare("
SELECT 1
FROM notificationlog
WHERE iduser = :uid
AND notificationtype = :tipo
AND idreference = :idref
AND daysbefore = :giorni
AND status = 'sent'
LIMIT 1
");
$stmt->execute([
':uid' => $iduser,
':tipo' => $tipo,
':idref' => $idreference,
':giorni' => $daysbefore,
]);
return (bool) $stmt->fetchColumn();
}
/**
* Scrive l'esito dell'invio nello storico.
*
* La chiave unica impedisce i doppioni; se la riga esiste gia' (tentativo
* precedente fallito) viene aggiornata con l'esito nuovo.
*/
function pushRegistraInvio(
PDO $db,
int $iduser,
string $tipo,
int $idreference,
int $daysbefore,
array $esito
): void {
$stmt = $db->prepare("
INSERT INTO notificationlog
(iduser, notificationtype, idreference, daysbefore, status, devicecount, errormessage)
VALUES
(:uid, :tipo, :idref, :giorni, :stato, :dispositivi, :errore)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
devicecount = VALUES(devicecount),
errormessage = VALUES(errormessage),
sent_at = NOW()
");
$stmt->execute([
':uid' => $iduser,
':tipo' => $tipo,
':idref' => $idreference,
':giorni' => $daysbefore,
':stato' => $esito['sent'] > 0 ? 'sent' : 'failed',
':dispositivi' => $esito['sent'],
':errore' => $esito['errors'] ? substr(implode(' | ', $esito['errors']), 0, 255) : null,
]);
}