375 lines
13 KiB
PHP
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,
|
|
]);
|
|
}
|