Files
yogibook_aury_new/public/newsletter_api.php
T
2026-10-19 08:41:08 +02:00

479 lines
23 KiB
PHP

<?php
/**
* API dell'editor newsletter (risponde sempre in JSON, tranne "preview" che restituisce HTML).
*
* GET action=load&id= bozza completa
* POST action=save salvataggio automatico (controllo revisione)
* GET action=versions&id= storico versioni
* GET action=version_html&idversion= anteprima di una versione (HTML)
* POST action=snapshot salva una versione con nome
* POST action=restore ripristina una versione
* POST action=preview HTML di anteprima + controlli
* POST action=upload_image carica e ottimizza un'immagine
* POST action=send_test invia una prova
* POST action=publish crea/aggiorna la bozza in The Newsletter
* POST action=create|duplicate|delete
* GET action=list_wp newsletter presenti in WordPress
* POST action=import_wp importa una newsletter di WordPress
*
* NB: la protezione della pagina (solo staff) va aggiunta come per le altre pagine admin.
*/
ob_start();
require_once('include/headscript.php');
require_once(__DIR__ . '/class/mailer.php');
require_once(__DIR__ . '/class/NewsletterRenderer.php');
require_once(__DIR__ . '/class/NewsletterWp.php');
function out(array $payload, int $code = 200): void
{
if (ob_get_length() !== false) ob_end_clean();
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function fail(string $msg, int $code = 400, array $extra = []): void
{
out(['success' => false, 'message' => $msg] + $extra, $code);
}
$pdo = DBHandlerSelect::getInstance()->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$userId = (int) $iduserlogin;
$action = $_REQUEST['action'] ?? '';
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
const NL_MAX_JSON = 2 * 1024 * 1024; // 2 MB di blocchi bastano e avanzano
/* -------------------------------------------------------------------------
* Funzioni di supporto
* ---------------------------------------------------------------------- */
function draftRow(PDO $pdo, int $id): array
{
$st = $pdo->prepare("SELECT * FROM newsletter_drafts WHERE iddraft = ? AND deleted_at IS NULL");
$st->execute([$id]);
$d = $st->fetch(PDO::FETCH_ASSOC);
if (!$d) fail('Newsletter non trovata.', 404);
return $d;
}
function draftPayload(array $d): array
{
$wpStatus = null;
if (!empty($d['wp_email_id']) && NewsletterWp::isConfigured()) {
try {
$wpStatus = NewsletterWp::statuses([(int) $d['wp_email_id']])[(int) $d['wp_email_id']] ?? 'deleted';
} catch (Throwable $e) {
$wpStatus = null;
}
}
return [
'id' => (int) $d['iddraft'],
'title' => $d['title'],
'subject' => $d['subject'],
'preheader' => $d['preheader'],
'blocks' => json_decode($d['blocks'] ?: '[]', true) ?: [],
'settings' => NewsletterRenderer::settings(json_decode($d['settings'] ?: '{}', true) ?: []),
'revision' => (int) $d['revision'],
'updated_at' => $d['updated_at'],
'wp_email_id' => $d['wp_email_id'] !== null ? (int) $d['wp_email_id'] : null,
'wp_pushed_at' => $d['wp_pushed_at'],
'wp_status' => $wpStatus,
];
}
/** Legge e valida blocchi + impostazioni inviati dall'editor. */
function readContent(): array
{
$blocksRaw = (string) ($_POST['blocks'] ?? '[]');
$settingsRaw = (string) ($_POST['settings'] ?? '{}');
if (strlen($blocksRaw) > NL_MAX_JSON) fail('La newsletter è troppo grande.', 413);
$blocks = json_decode($blocksRaw, true);
$settings = json_decode($settingsRaw, true);
if (!is_array($blocks) || !is_array($settings)) fail('Dati non validi.', 422);
$clean = [];
foreach ($blocks as $b) {
if (!is_array($b) || !in_array($b['type'] ?? '', NewsletterRenderer::BLOCK_TYPES, true)) continue;
$b['id'] = preg_replace('/[^a-z0-9]/i', '', (string) ($b['id'] ?? '')) ?: bin2hex(random_bytes(6));
$clean[] = $b;
}
return [
'title' => mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 255),
'subject' => mb_substr(trim((string) ($_POST['subject'] ?? '')), 0, 255),
'preheader' => mb_substr(trim((string) ($_POST['preheader'] ?? '')), 0, 255),
'blocks' => $clean,
'settings' => $settings,
];
}
function addVersion(PDO $pdo, array $d, string $note, int $userId): void
{
$pdo->prepare("INSERT INTO newsletter_draft_versions
(iddraft, revision, subject, preheader, blocks, settings, note, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())")
->execute([(int) $d['iddraft'], (int) $d['revision'], $d['subject'], $d['preheader'], $d['blocks'], $d['settings'], mb_substr($note, 0, 100), $userId]);
// Tiene le ultime 200 versioni per newsletter
$pdo->prepare("DELETE FROM newsletter_draft_versions
WHERE iddraft = ? AND idversion NOT IN (
SELECT idversion FROM (SELECT idversion FROM newsletter_draft_versions WHERE iddraft = ? ORDER BY idversion DESC LIMIT 200) keep
)")->execute([(int) $d['iddraft'], (int) $d['iddraft']]);
}
function createDraft(PDO $pdo, int $userId, string $title, string $subject, string $preheader, array $blocks, array $settings, ?int $wpId = null): int
{
$pdo->prepare("INSERT INTO newsletter_drafts
(title, subject, preheader, blocks, settings, revision, wp_email_id, created_by, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NOW(), NOW())")
->execute([$title, $subject, $preheader, json_encode($blocks, JSON_UNESCAPED_UNICODE), json_encode($settings, JSON_UNESCAPED_UNICODE), $wpId, $userId, $userId]);
return (int) $pdo->lastInsertId();
}
function baseUrl(): string
{
if (!empty($_ENV['NEWSLETTER_BASE_URL'])) return rtrim($_ENV['NEWSLETTER_BASE_URL'], '/');
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
$dir = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $dir;
}
/* -------------------------------------------------------------------------
* Azioni
* ---------------------------------------------------------------------- */
try {
switch ($action) {
case 'load':
out(['success' => true, 'draft' => draftPayload(draftRow($pdo, (int) ($_GET['id'] ?? 0)))]);
case 'save':
if (!$isPost) fail('Metodo non consentito.', 405);
$id = (int) ($_POST['id'] ?? 0);
$rev = (int) ($_POST['revision'] ?? -1);
$force = ($_POST['force'] ?? '') === '1';
$c = readContent();
$sql = "UPDATE newsletter_drafts
SET title = ?, subject = ?, preheader = ?, blocks = ?, settings = ?,
revision = revision + 1, updated_by = ?, updated_at = NOW()
WHERE iddraft = ? AND deleted_at IS NULL" . ($force ? '' : ' AND revision = ?');
$params = [
$c['title'],
$c['subject'],
$c['preheader'],
json_encode($c['blocks'], JSON_UNESCAPED_UNICODE),
json_encode($c['settings'], JSON_UNESCAPED_UNICODE),
$userId,
$id
];
if (!$force) $params[] = $rev;
$st = $pdo->prepare($sql);
$st->execute($params);
$d = draftRow($pdo, $id);
if ($st->rowCount() === 0) {
fail('Questa newsletter è stata salvata da un\'altra parte (un\'altra scheda o un altro computer).', 409, [
'conflict' => true,
'revision' => (int) $d['revision'],
'updated_at' => $d['updated_at'],
]);
}
// Versione automatica ogni 10 minuti di lavoro
$st = $pdo->prepare("SELECT MAX(created_at) FROM newsletter_draft_versions WHERE iddraft = ?");
$st->execute([$id]);
$last = $st->fetchColumn();
if (!$last || strtotime($last) < time() - 600) {
addVersion($pdo, $d, 'Automatica', $userId);
}
out(['success' => true, 'revision' => (int) $d['revision'], 'updated_at' => $d['updated_at']]);
case 'versions':
$st = $pdo->prepare("SELECT v.idversion, v.revision, v.note, v.created_at, v.subject,
TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) AS author
FROM newsletter_draft_versions v
LEFT JOIN auth_users u ON u.id = v.created_by
WHERE v.iddraft = ?
ORDER BY v.idversion DESC LIMIT 200");
$st->execute([(int) ($_GET['id'] ?? 0)]);
out(['success' => true, 'versions' => $st->fetchAll(PDO::FETCH_ASSOC)]);
case 'version_html':
$st = $pdo->prepare("SELECT * FROM newsletter_draft_versions WHERE idversion = ?");
$st->execute([(int) ($_GET['idversion'] ?? 0)]);
$v = $st->fetch(PDO::FETCH_ASSOC);
if (!$v) fail('Versione non trovata.', 404);
if (ob_get_length() !== false) ob_end_clean();
header('Content-Type: text/html; charset=utf-8');
echo NewsletterRenderer::renderDocument(json_decode($v['blocks'] ?: '[]', true) ?: [], json_decode($v['settings'] ?: '{}', true) ?: [], $v['subject'], $v['preheader'], true);
exit;
case 'snapshot':
if (!$isPost) fail('Metodo non consentito.', 405);
$d = draftRow($pdo, (int) ($_POST['id'] ?? 0));
addVersion($pdo, $d, trim((string) ($_POST['note'] ?? '')) ?: 'Salvata a mano', $userId);
out(['success' => true]);
case 'restore':
if (!$isPost) fail('Metodo non consentito.', 405);
$id = (int) ($_POST['id'] ?? 0);
$d = draftRow($pdo, $id);
$st = $pdo->prepare("SELECT * FROM newsletter_draft_versions WHERE idversion = ? AND iddraft = ?");
$st->execute([(int) ($_POST['idversion'] ?? 0), $id]);
$v = $st->fetch(PDO::FETCH_ASSOC);
if (!$v) fail('Versione non trovata.', 404);
$pdo->beginTransaction();
addVersion($pdo, $d, 'Prima del ripristino', $userId);
$pdo->prepare("UPDATE newsletter_drafts SET subject = ?, preheader = ?, blocks = ?, settings = ?,
revision = revision + 1, updated_by = ?, updated_at = NOW() WHERE iddraft = ?")
->execute([$v['subject'], $v['preheader'], $v['blocks'], $v['settings'], $userId, $id]);
$pdo->commit();
out(['success' => true, 'draft' => draftPayload(draftRow($pdo, $id))]);
case 'preview':
if (!$isPost) fail('Metodo non consentito.', 405);
$c = readContent();
$real = NewsletterRenderer::renderDocument($c['blocks'], $c['settings'], $c['subject'], $c['preheader']);
out([
'success' => true,
'html' => NewsletterRenderer::renderDocument($c['blocks'], $c['settings'], $c['subject'], $c['preheader'], true),
'kb' => (int) round(strlen($real) / 1024),
'checks' => NewsletterRenderer::checks($c['blocks'], $c['subject'], $c['preheader'], $real),
]);
case 'upload_image':
if (!$isPost) fail('Metodo non consentito.', 405);
$f = $_FILES['image'] ?? null;
if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$why = [
UPLOAD_ERR_INI_SIZE => 'il file supera il limite del server (upload_max_filesize)',
UPLOAD_ERR_FORM_SIZE => 'il file è troppo grande',
UPLOAD_ERR_PARTIAL => 'caricamento interrotto',
UPLOAD_ERR_NO_FILE => 'nessun file ricevuto',
UPLOAD_ERR_NO_TMP_DIR => 'manca la cartella temporanea sul server',
UPLOAD_ERR_CANT_WRITE => 'il server non riesce a scrivere il file',
][$f['error'] ?? UPLOAD_ERR_NO_FILE] ?? 'errore sconosciuto';
fail('Caricamento non riuscito: ' . $why . '.');
}
if ($f['size'] > 15 * 1024 * 1024) fail('Immagine troppo grande (massimo 15 MB).');
$info = @getimagesize($f['tmp_name']);
$mime = $info['mime'] ?? '';
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$mime] ?? null;
if (!$ext) fail('Formato non supportato: usa JPG, PNG, GIF o WebP.');
$dirRel = 'assets/uploads/newsletter/' . date('Y/m');
$dirAbs = __DIR__ . '/' . $dirRel;
if (!is_dir($dirAbs) && !@mkdir($dirAbs, 0755, true)) {
fail('Impossibile creare la cartella ' . $dirRel . ': controlla i permessi di assets/uploads.', 500);
}
if (!is_writable($dirAbs)) fail('La cartella ' . $dirRel . ' non è scrivibile: controlla i permessi.', 500);
$name = date('Ymd_His') . '_' . bin2hex(random_bytes(4));
[$w, $h] = $info;
$note = '';
// Si può ottimizzare? Serve GD con il formato giusto
$reader = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng', 'image/webp' => 'imagecreatefromwebp'][$mime] ?? null;
$canResize = $mime !== 'image/gif' && $reader && function_exists($reader) && function_exists('imagecreatetruecolor');
if (!$canResize) {
// GIF (forse animata) oppure server senza GD: salviamo l'originale
if ($mime !== 'image/gif' && $f['size'] > 3 * 1024 * 1024) {
fail('Il server non può ridimensionare le immagini (manca la libreria GD) e questa supera 3 MB: riducila prima di caricarla.');
}
$file = $name . '.' . $ext;
if (!move_uploaded_file($f['tmp_name'], $dirAbs . '/' . $file)) fail('Impossibile salvare l\'immagine.', 500);
if ($mime !== 'image/gif') $note = 'Caricata senza ottimizzazione (sul server manca la libreria GD).';
} else {
try {
@ini_set('memory_limit', '512M'); // le foto dal telefono sono grandi
$src = @$reader($f['tmp_name']);
if (!$src) fail('Immagine non leggibile o danneggiata.');
// Foto dal telefono: raddrizza secondo l'orientamento EXIF
if ($mime === 'image/jpeg' && function_exists('exif_read_data')) {
$o = (int) (@exif_read_data($f['tmp_name'])['Orientation'] ?? 1);
$rot = [3 => 180, 6 => -90, 8 => 90][$o] ?? 0;
if ($rot) {
$src = imagerotate($src, $rot, 0);
$w = imagesx($src);
$h = imagesy($src);
}
}
$nw = min($w, 1200); // il doppio della larghezza email: nitida sugli schermi retina
$nh = max(1, (int) round($h * $nw / $w));
// PNG con trasparenza resta PNG, il resto diventa JPG (molto più leggero)
$keepPng = false;
if ($mime === 'image/png') {
$ct = ord((string) file_get_contents($f['tmp_name'], false, null, 25, 1));
$keepPng = in_array($ct, [4, 6], true);
}
$dst = imagecreatetruecolor($nw, $nh);
if ($keepPng) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
} else {
imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
$file = $name . ($keepPng ? '.png' : '.jpg');
$ok = $keepPng ? imagepng($dst, $dirAbs . '/' . $file, 9) : imagejpeg($dst, $dirAbs . '/' . $file, 82);
imagedestroy($src);
imagedestroy($dst);
if (!$ok) fail('Impossibile salvare l\'immagine ottimizzata.', 500);
[$w, $h] = [$nw, $nh];
} catch (Throwable $e) {
error_log('newsletter_api [upload_image]: ' . $e->getMessage());
fail('Ottimizzazione non riuscita: ' . $e->getMessage(), 500);
}
}
out([
'success' => true,
'url' => baseUrl() . '/' . $dirRel . '/' . $file,
'width' => $w,
'height' => $h,
'kb' => (int) round(filesize($dirAbs . '/' . $file) / 1024),
'note' => $note,
]);
case 'send_test':
if (!$isPost) fail('Metodo non consentito.', 405);
$d = draftRow($pdo, (int) ($_POST['id'] ?? 0));
$to = trim((string) ($_POST['to'] ?? ''));
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) fail('Indirizzo email non valido.');
$p = draftPayload($d);
$html = NewsletterRenderer::renderDocument($p['blocks'], $p['settings'], $p['subject'], $p['preheader'], true);
$clean = fn($v) => trim((string) $v, "\" \t\n\r\0\x0B");
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = $clean($_ENV['MAIL_HOST'] ?? '');
$mail->SMTPAuth = true;
$mail->Username = $_ENV['MAIL_USERNAME'] ?? '';
$mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
$mail->SMTPSecure = $clean($_ENV['MAIL_ENCRYPTION'] ?? 'ssl');
$mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 465);
$mail->Timeout = 20;
$mail->CharSet = 'UTF-8';
$mail->setFrom($clean($_ENV['MAIL_FROM_ADDRESS'] ?? ''), $clean($_ENV['MAIL_FROM_NAME'] ?? 'YogaSoul'));
$mail->addAddress($to);
$mail->isHTML(true);
$mail->Subject = '[PROVA] ' . ($p['subject'] ?: '(senza oggetto)');
$mail->Body = $html;
$mail->AltBody = str_replace('{name}', 'Maria', NewsletterRenderer::renderText($p['blocks'], $p['preheader']));
$mail->send();
out(['success' => true, 'message' => 'Prova inviata a ' . $to . '.']);
case 'publish':
if (!$isPost) fail('Metodo non consentito.', 405);
$id = (int) ($_POST['id'] ?? 0);
$d = draftRow($pdo, $id);
$p = draftPayload($d);
$html = NewsletterRenderer::renderDocument($p['blocks'], $p['settings'], $p['subject'], $p['preheader']);
$checks = NewsletterRenderer::checks($p['blocks'], $p['subject'], $p['preheader'], $html);
$errors = array_filter($checks, fn($c) => $c[0] === 'error');
if ($errors && ($_POST['confirm'] ?? '') !== '1') {
out(['success' => false, 'needs_confirm' => true, 'checks' => $checks, 'message' => 'Ci sono problemi da controllare.']);
}
if (trim($p['subject']) === '') fail("Manca l'oggetto: in WordPress non si può inviare senza.");
$text = NewsletterRenderer::renderText($p['blocks'], $p['preheader']);
[$wpId, $created] = NewsletterWp::push($p['wp_email_id'], $p['subject'], $p['preheader'], $html, $text, $p['settings']);
$pdo->prepare("UPDATE newsletter_drafts SET wp_email_id = ?, wp_pushed_at = NOW() WHERE iddraft = ?")->execute([$wpId, $id]);
addVersion($pdo, $d, 'Pubblicata su The Newsletter', $userId);
out([
'success' => true,
'wp_id' => $wpId,
'created' => $created,
'admin_url' => NewsletterWp::adminUrl(),
'message' => $created
? "Creata in The Newsletter (n. $wpId). Il pubblico è impostato come l'ultima newsletter: controllalo prima di inviare."
: "Aggiornata in The Newsletter (n. $wpId).",
]);
case 'create':
if (!$isPost) fail('Metodo non consentito.', 405);
$title = trim((string) ($_POST['title'] ?? '')) ?: 'Newsletter del ' . date('d/m/Y');
$id = createDraft($pdo, $userId, $title, '', '', NewsletterRenderer::starterBlocks(), NewsletterRenderer::defaultSettings());
out(['success' => true, 'id' => $id]);
case 'duplicate':
if (!$isPost) fail('Metodo non consentito.', 405);
$d = draftRow($pdo, (int) ($_POST['id'] ?? 0));
$blocks = json_decode($d['blocks'] ?: '[]', true) ?: [];
foreach ($blocks as &$b) $b['id'] = bin2hex(random_bytes(6));
unset($b);
$id = createDraft($pdo, $userId, 'Copia di ' . $d['title'], $d['subject'], $d['preheader'], $blocks, json_decode($d['settings'] ?: '{}', true) ?: []);
out(['success' => true, 'id' => $id]);
case 'delete':
if (!$isPost) fail('Metodo non consentito.', 405);
$pdo->prepare("UPDATE newsletter_drafts SET deleted_at = NOW(), updated_by = ? WHERE iddraft = ?")
->execute([$userId, (int) ($_POST['id'] ?? 0)]);
out(['success' => true]);
case 'list_wp':
out(['success' => true, 'emails' => NewsletterWp::listEmails(40)]);
case 'import_wp':
if (!$isPost) fail('Metodo non consentito.', 405);
$wpId = (int) ($_POST['wp_id'] ?? 0);
$imp = NewsletterWp::import($wpId);
// Se in WordPress non è ancora partita, la colleghiamo: "Pubblica" aggiornerà quella
$status = NewsletterWp::statuses([$wpId])[$wpId] ?? '';
$id = createDraft(
$pdo,
$userId,
$imp['subject'] ?: 'Importata da WordPress',
$imp['subject'],
$imp['preheader'],
$imp['blocks'],
$imp['settings'],
$status === 'new' ? $wpId : null
);
out(['success' => true, 'id' => $id, 'linked' => $status === 'new']);
default:
fail('Azione sconosciuta.', 404);
}
} catch (\PHPMailer\PHPMailer\Exception $e) {
fail('Invio non riuscito: ' . $e->getMessage(), 502);
} catch (PDOException $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('newsletter_api [' . $action . '] DB: ' . $e->getMessage());
fail('Errore del database. Riprova; se continua controlla il log.', 500);
} catch (RuntimeException $e) {
fail($e->getMessage(), 409);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('newsletter_api [' . $action . ']: ' . $e->getMessage() . ' @' . $e->getFile() . ':' . $e->getLine());
fail('Errore del server. Riprova; se continua controlla il log.', 500);
}