movmaker-webui/lib/send_push.php

327 lines
13 KiB
PHP

<?php
/**
* send_push.php
* Helper function to send "Show enabled" web-push notifications from the webserver.
*
* Function: try_send_show_notification(string $articleRefOrJob, string $jobdir, string $mvlog_root = null, string $logfile = null): bool
*
* Returns true on successful send and cache update, false otherwise.
*/
function mvlog_load_push_config(string $mvlog_root): ?array
{
$candidates = [
'/etc/mvlog/push.php',
'/etc/mvlog/push.json',
$mvlog_root . '/push.json',
(getenv('HOME') !== false ? getenv('HOME') . '/.config/mvlog/push.json' : null),
];
foreach ($candidates as $candidate) {
if (!$candidate || !is_file($candidate) || !is_readable($candidate)) continue;
$cfg = null;
if (str_ends_with($candidate, '.php')) {
$loaded = @require $candidate;
if (is_array($loaded)) $cfg = $loaded;
} else {
$decoded = json_decode((string)@file_get_contents($candidate), true);
if (is_array($decoded)) $cfg = $decoded;
}
if (!is_array($cfg)) continue;
$public = (string)($cfg['public_key'] ?? $cfg['vapidPublicKey'] ?? '');
$private = (string)($cfg['private_key'] ?? $cfg['vapidPrivateKey'] ?? '');
$subject = (string)($cfg['subject'] ?? $cfg['vapidSubject'] ?? '');
if ($public !== '' && $private !== '') {
return [
'subject' => $subject,
'public_key' => $public,
'private_key' => $private,
'_source' => $candidate,
];
}
}
return null;
}
function mvlog_write_temp_push_json(array $cfg): ?string
{
$tmp = tempnam(sys_get_temp_dir(), 'mvlog-push-');
if ($tmp === false) return null;
$payload = json_encode([
'subject' => (string)($cfg['subject'] ?? ''),
'public_key' => (string)($cfg['public_key'] ?? ''),
'private_key' => (string)($cfg['private_key'] ?? ''),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload) || @file_put_contents($tmp, $payload, LOCK_EX) === false) {
@unlink($tmp);
return null;
}
@chmod($tmp, 0600);
return $tmp;
}
function mvlog_nice_title(string $s): string
{
return trim(ucwords(str_replace(['_', '-'], ' ', $s)));
}
function mvlog_article_id_is_valid(string $id): bool
{
return (bool)preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id);
}
function mvlog_read_article_id(string $jobdir): string
{
$path = rtrim($jobdir, '/') . '/.mvlog-id';
if (!is_file($path)) return '';
$value = strtolower(trim((string)@file_get_contents($path)));
return mvlog_article_id_is_valid($value) ? $value : '';
}
function mvlog_read_job_data_fields(string $jobdir): array
{
$data = ['title' => '', 'teaser' => '', 'description' => ''];
$file = rtrim($jobdir, '/') . '/data.txt';
if (!is_file($file) || !is_readable($file)) return $data;
$lines = @file($file, FILE_IGNORE_NEW_LINES);
if (!is_array($lines)) return $data;
$current = '';
foreach ($lines as $line) {
$trim = trim((string)$line);
if ($trim === '') {
if ($current === 'description') $data['description'] .= "\n";
continue;
}
if (preg_match('/^([A-Za-z_ -]+):\s*(.*)$/', $trim, $m)) {
$key = strtolower(trim((string)$m[1]));
$value = trim((string)$m[2]);
if ($key === 'title') { $data['title'] = $value; $current = 'title'; }
elseif (in_array($key, ['teaser', 'tagline', 'subtitle'], true)) { $data['teaser'] = $value; $current = 'teaser'; }
elseif (in_array($key, ['description', 'desc', 'text'], true)) { $data['description'] = $value; $current = 'description'; }
else { $current = ''; }
} elseif ($current === 'description') {
$data['description'] .= ($data['description'] !== '' ? "\n" : '') . $trim;
}
}
foreach ($data as $k => $v) $data[$k] = trim((string)$v);
return $data;
}
function mvlog_random_newsletter_title(string $mvlog_root, string $logfile): string
{
$titles_file = rtrim($mvlog_root, '/') . '/header_titles.php';
$titles = is_file($titles_file) && is_readable($titles_file) ? @require $titles_file : [];
if (!is_array($titles)) $titles = [];
$clean = [];
foreach ($titles as $title) {
$title = trim((string)$title);
if ($title !== '') $clean[] = $title;
}
if (!$clean) {
error_log("[mvlog] header_titles.php missing/empty, using default newsletter title\n", 3, $logfile);
return 'Mistakes, Views & Luck';
}
return $clean[array_rand($clean)];
}
function mvlog_try_create_journal_campaign(string $articleId, string $articleTitle, string $articleUrl, string $articleExcerpt, string $newsletterTitle, string $mvlog_root, string $logfile): void
{
$helper = $mvlog_root . '/lib/listmonk.php';
if (!is_file($helper) || !is_readable($helper)) {
error_log("[mvlog] listmonk helper missing at $helper, skipping journal campaign\n", 3, $logfile);
return;
}
try {
include_once $helper;
if (!function_exists('listmonkCreateCampaign')) {
error_log("[mvlog] listmonkCreateCampaign missing in $helper, skipping journal campaign\n", 3, $logfile);
return;
}
listmonkCreateCampaign($articleId, $articleUrl, $articleTitle, $articleExcerpt, $newsletterTitle);
error_log("[mvlog] journal campaign created for $articleUrl\n", 3, $logfile);
} catch (Throwable $e) {
error_log("[mvlog] journal campaign failed for $articleUrl: " . $e->getMessage() . "\n", 3, $logfile);
}
}
function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = null, $logfile = null)
{
if ($mvlog_root === null) $mvlog_root = dirname(__DIR__);
if ($logfile === null) $logfile = '/var/log/mvlog_notify.log';
$subs_file = $mvlog_root . '/cache/push_subscriptions.json';
$cache_file = $mvlog_root . '/cache/push_notifications.json';
$job = basename((string)$jobdir);
$articleRef = strtolower(trim((string)$articleRefOrJob));
$articleId = mvlog_article_id_is_valid($articleRef) ? $articleRef : mvlog_read_article_id((string)$jobdir);
if ($articleId === '') {
error_log("[mvlog] article id missing, skipping announcement for $job\n", 3, $logfile);
return false;
}
$identity = $articleId;
// Don't announce hidden jobs.
if (is_file($jobdir . '/.mvlog-hidden')) {
error_log("[mvlog] job $job hidden, skipping announcement\n", 3, $logfile);
return false;
}
// Read cache and check if already announced.
$cache = [];
if (is_file($cache_file)) {
$cache = json_decode((string)@file_get_contents($cache_file), true) ?: [];
}
$shown = isset($cache['shown']) && is_array($cache['shown']) ? $cache['shown'] : [];
// Transition support: honor both new (ID) and legacy (job name) keys.
if (isset($shown[$identity]) || ($identity !== $job && isset($shown[$job]))) {
error_log("[mvlog] announcement already sent for $identity (job=$job), skipping\n", 3, $logfile);
return false;
}
// Determine article title (used in push text and journal campaign).
$articleTitle = mvlog_nice_title((string)$job);
if (preg_match('/^\d{8}[_-](.+)$/', (string)$job, $m)) {
$articleTitle = mvlog_nice_title((string)$m[1]);
}
$state_file = $jobdir . '/.movmaker-state.json';
if (is_file($state_file)) {
$state = json_decode((string)@file_get_contents($state_file), true) ?: [];
if (!empty($state['output']) && is_string($state['output'])) {
$out = pathinfo($state['output'], PATHINFO_FILENAME);
if ($out) {
if (preg_match('/^\d{8}[_-](.+)$/', (string)$out, $m)) {
$articleTitle = mvlog_nice_title((string)$m[1]);
} else {
$articleTitle = mvlog_nice_title((string)$out);
}
}
}
}
if ($articleTitle === '') $articleTitle = mvlog_nice_title((string)$job);
$jobData = mvlog_read_job_data_fields((string)$jobdir);
$journalTitle = (string)($jobData['title'] !== '' ? $jobData['title'] : $articleTitle);
$newsletterTitle = mvlog_random_newsletter_title($mvlog_root, $logfile);
$articleExcerpt = (string)($jobData['teaser'] !== '' ? $jobData['teaser'] : $jobData['description']);
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($articleExcerpt) > 500) $articleExcerpt = rtrim(mb_substr($articleExcerpt, 0, 497)) . '…';
} elseif (strlen($articleExcerpt) > 500) {
$articleExcerpt = rtrim(substr($articleExcerpt, 0, 497)) . '...';
}
$articleUrl = is_file($jobdir . '/.mvlog-permalink') ? './?id=' . rawurlencode($articleId) : './';
$journalUrl = 'https://bubulescu.org/' . (is_file($jobdir . '/.mvlog-permalink') ? '?id=' . rawurlencode($articleId) : '');
// Attempt web-push, but never let push failure prevent the journal/Listmonk campaign.
$pushOk = false;
$pushStatus = 'skipped';
$cfgSource = 'none';
$subs_raw = is_file($subs_file) ? @file_get_contents($subs_file) : false;
$subs = is_string($subs_raw) && trim($subs_raw) !== '' ? json_decode($subs_raw, true) : null;
if (!is_array($subs) || count($subs) === 0) {
error_log("[mvlog] subscriptions missing/empty/invalid, skipping web-push for $identity\n", 3, $logfile);
} else {
$cfg = mvlog_load_push_config($mvlog_root);
if (!$cfg) {
error_log("[mvlog] push config not found/invalid, skipping web-push for $identity\n", 3, $logfile);
} else {
$cfgSource = (string)($cfg['_source'] ?? 'unknown');
$tmp_push_config = mvlog_write_temp_push_json($cfg);
if (!$tmp_push_config) {
error_log("[mvlog] failed to create temp push config for $identity\n", 3, $logfile);
} else {
$payload = json_encode([
'title' => $articleTitle,
'body' => 'New unreliable article @ MVLog!',
'url' => $articleUrl,
'tag' => "mvlog-show-{$identity}",
], JSON_UNESCAPED_UNICODE);
$send_script = escapeshellarg($mvlog_root . '/send_push.js');
$push_config_esc = escapeshellarg($tmp_push_config);
$payload_esc = escapeshellarg((string)$payload);
$cmd = "node $send_script $push_config_esc $payload_esc";
$descriptorspec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptorspec, $pipes);
if (!is_resource($proc)) {
error_log("[mvlog] failed to start node send_push process for $identity\n", 3, $logfile);
$pushStatus = 'failed-start';
} else {
fwrite($pipes[0], (string)$subs_raw);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
$rc = proc_close($proc);
if ($rc === 0) {
$pushOk = true;
$pushStatus = 'sent';
} else {
$pushStatus = 'failed';
error_log("[mvlog] send_push failed for $identity rc=$rc stdout=" . trim((string)$stdout) . " stderr=" . trim((string)$stderr) . "\n", 3, $logfile);
}
}
@unlink($tmp_push_config);
}
}
}
// Always attempt the journal/Listmonk campaign for the same eligible trigger.
mvlog_try_create_journal_campaign($articleId, $journalTitle, $journalUrl, $articleExcerpt, $newsletterTitle, $mvlog_root, $logfile);
// Record the announcement so the same article is not announced repeatedly.
if (!is_array($cache)) $cache = [];
unset($cache['sent']);
if (!isset($cache['shown']) || !is_array($cache['shown'])) $cache['shown'] = [];
$cache['shown'][$identity] = [
'sent_at' => date(DATE_ATOM),
'job' => $job,
'article_id' => $articleId,
'title' => $journalTitle,
'newsletter_title' => $newsletterTitle,
'push' => ($pushOk ? 'successful' : 'failed'),
];
$tmp = $cache_file . '.tmp.' . bin2hex(random_bytes(6));
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
error_log("[mvlog] failed to write cache temp file for $identity\n", 3, $logfile);
return false;
}
@chmod($tmp, 0664);
if (!@rename($tmp, $cache_file)) {
@unlink($tmp);
error_log("[mvlog] failed to atomically write cache file for $identity\n", 3, $logfile);
return false;
}
error_log("[mvlog] announcement processed: identity=$identity job=$job push=$pushStatus (cfg=$cfgSource)\n", 3, $logfile);
return true;
}