$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, bool $sendImmediately, 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, $sendImmediately); 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, bool $sendImmediately = false) { 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, $sendImmediately, $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, 'send_immediately' => $sendImmediately, '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; }