diff --git a/admin.php b/admin.php index 7ebc3b3..d3859a6 100644 --- a/admin.php +++ b/admin.php @@ -1141,6 +1141,7 @@ try { $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); $articleId = ensure_state_article_id($dir); $visibleNow = !empty($_POST['visible']); + $sendImmediately = !empty($_POST['send_immediately']); $state = input_dir_state($dir); $output = basename((string)($state['output'] ?? '')); $canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir); @@ -1153,7 +1154,7 @@ try { if ($visibleNow && $canShow) { if (is_file(__DIR__ . '/lib/send_push.php')) { include_once __DIR__ . '/lib/send_push.php'; - try_send_show_notification($articleId ?: basename($dir), $dir, __DIR__, '/var/log/mvlog_notify.log'); + try_send_show_notification($articleId ?: basename($dir), $dir, __DIR__, '/var/log/mvlog_notify.log', $sendImmediately); } else { error_log("[mvlog] send_push helper missing, cannot send notification for " . basename($dir) . " ", 3, '/var/log/mvlog_notify.log'); @@ -1906,17 +1907,65 @@ function initSwitchForms(){ }); } +function ensureShowPromptStyles(){ + if(document.getElementById('show-prompt-styles')) return; + const style = document.createElement('style'); + style.id = 'show-prompt-styles'; + style.textContent = '#show-prompt-overlay{position:fixed;inset:0;background:rgba(17,24,39,.48);backdrop-filter:blur(1px);display:flex;align-items:center;justify-content:center;z-index:2995;}' + + '#show-prompt-overlay .panel{background:rgba(15,23,42,.96);color:#F3F4F6;border:1px solid rgba(255,255,255,.14);box-shadow:0 20px 60px rgba(0,0,0,.38);border-radius:16px;padding:22px 28px;min-width:min(360px,calc(100vw - 2rem));max-width:460px;}' + + '#show-prompt-overlay h3{margin:.1rem 0 .65rem;font-size:1.1rem;}' + + '#show-prompt-overlay p{margin:.45rem 0 .9rem;color:#A0A4AB;line-height:1.45;}' + + '#show-prompt-overlay .prompt-check{display:flex;align-items:center;gap:.55rem;margin:.8rem 0 1rem;font-weight:600;}' + + '#show-prompt-overlay .prompt-check input{display:inline-block;width:auto;margin:0;}' + + '#show-prompt-overlay .actions{display:flex;justify-content:flex-end;gap:.55rem;margin-top:.8rem;}'; + document.head.appendChild(style); +} + +function promptSendEmailsImmediately(){ + ensureShowPromptStyles(); + return new Promise(function(resolve){ + const overlay = document.createElement('div'); + overlay.id = 'show-prompt-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + const checkbox = overlay.querySelector('#show-send-immediately'); + const ok = overlay.querySelector('#show-prompt-ok'); + ok.addEventListener('click', function(){ + const value = !!(checkbox && checkbox.checked); + overlay.remove(); + resolve(value); + }); + ok.focus(); + }); +} + async function handleSwitchToggle(form, checkbox){ if(checkbox.disabled) return; const previousState = checkbox.dataset.state === '1'; const data = new FormData(form); data.set('ajax', '1'); + const useBusyOverlay = form.dataset.switch === 'show'; + const isShowingNow = useBusyOverlay && checkbox.checked && !previousState; + let sendImmediately = false; + if(isShowingNow){ + sendImmediately = await promptSendEmailsImmediately(); + } if(checkbox.checked){ data.set(checkbox.name, '1'); }else{ data.delete(checkbox.name); } + if(sendImmediately){ + data.set('send_immediately', '1'); + } + const busyLabel = checkbox.checked ? 'Showing article…' : 'Hiding article…'; checkbox.disabled = true; + if(useBusyOverlay) showThumbBusy(busyLabel); try{ const res = await fetch('admin.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }}); const json = await res.json().catch(() => ({})); @@ -1929,6 +1978,7 @@ async function handleSwitchToggle(form, checkbox){ showToast(e.message || 'Switch update failed', false); }finally{ checkbox.disabled = false; + if(useBusyOverlay) setTimeout(hideThumbBusy, 120); } } diff --git a/lib/listmonk.php b/lib/listmonk.php new file mode 100644 index 0000000..385148d --- /dev/null +++ b/lib/listmonk.php @@ -0,0 +1,141 @@ + true, + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($json !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + } + + $response = curl_exec($ch); + if ($response === false) { + $error = curl_error($ch); + curl_close($ch); + throw new Exception('Listmonk API request failed: ' . $error); + } + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + } else { + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => "Content-Type: application/json\r\nAuthorization: Basic " . base64_encode($user . ':' . $pass) . "\r\n", + 'content' => $json ?? '', + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + $response = @file_get_contents($url, false, $context); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int)$m[1]; + } + if ($response === false) { + throw new Exception('Listmonk API request failed.'); + } + } + + if ($code < 200 || $code >= 300) { + throw new Exception('Listmonk API request failed. HTTP ' . $code . ': ' . $response); + } + + $decoded = json_decode((string)$response, true); + return is_array($decoded) ? $decoded : []; +} + +function listmonkCreateCampaign( + string $articleId, + string $articleUrl, + string $title, + string $excerpt, + string $newsletterTitle = 'Mistakes, Views & Luck', + bool $sendImmediately = false +): array { + $base = 'http://127.0.0.1:9000'; + + $user = 'mvlog'; + $pass = '1VLUsqSyIQ4xboEd2tmQvgCjWJJk0F3Q'; + + $listId = 3; // MVLog Readers + $templateId = 5; // Your Listmonk template ID + + $ogImageUrl = 'https://bubulescu.org/out-dir/' . rawurlencode($articleId) . '_og.jpg'; + + $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $safeExcerpt = nl2br(htmlspecialchars($excerpt, ENT_QUOTES, 'UTF-8')); + $safeArticleUrl = htmlspecialchars($articleUrl, ENT_QUOTES, 'UTF-8'); + $safeOgImageUrl = htmlspecialchars($ogImageUrl, ENT_QUOTES, 'UTF-8'); + +$body = ' + + ' . $safeTitle . ' + + +
+

+ ' . $safeTitle . ' +

+ +

+ ' . $safeExcerpt . ' +

+
+ +

+ + Read the story + +

+'; + + $payload = [ + 'name' => $newsletterTitle, + 'subject' => 'Story published: ' . $title, + 'lists' => [$listId], + 'from_email' => 'MVLog ', + 'type' => 'regular', + 'content_type' => 'html', + 'body' => $body, + 'template_id' => $templateId, + 'messenger' => 'email', + 'status' => 'draft', + 'headers' => [ + ['Reply-To' => 'mvlog@bubulescu.org'] + ], + ]; + + $decoded = listmonkApiRequest($base, $user, $pass, 'POST', '/api/campaigns', $payload); + if ($sendImmediately) { + $campaignId = $decoded['data']['id'] ?? $decoded['id'] ?? null; + if (!$campaignId) { + throw new Exception('Listmonk campaign create succeeded but campaign ID was not returned.'); + } + $sendResponse = listmonkApiRequest($base, $user, $pass, 'PUT', '/api/campaigns/' . rawurlencode((string)$campaignId) . '/status', [ + 'status' => 'running', + ]); + $decoded['send_response'] = $sendResponse; + } + + return $decoded; +} \ No newline at end of file diff --git a/lib/send_push.php b/lib/send_push.php index 7c87366..dfa7d8f 100644 --- a/lib/send_push.php +++ b/lib/send_push.php @@ -3,7 +3,7 @@ * 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 + * Function: try_send_show_notification(string $articleRefOrJob, string $jobdir, string $mvlog_root = null, string $logfile = null, bool $sendImmediately = false): bool * * Returns true on successful send and cache update, false otherwise. */ @@ -86,7 +86,81 @@ function mvlog_read_article_id(string $jobdir): string return mvlog_article_id_is_valid($value) ? $value : ''; } -function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = null, $logfile = null) +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'; @@ -98,34 +172,18 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul $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 push for $job\n", 3, $logfile); + error_log("[mvlog] article id missing, skipping announcement for $job\n", 3, $logfile); return false; } $identity = $articleId; - // Don't notify for hidden jobs + // Don't announce hidden jobs. if (is_file($jobdir . '/.mvlog-hidden')) { - error_log("[mvlog] job $job hidden, skipping push\n", 3, $logfile); + error_log("[mvlog] job $job hidden, skipping announcement\n", 3, $logfile); return false; } - // Gather subscriptions - if (!is_file($subs_file)) { - error_log("[mvlog] subscriptions file missing, skipping push for $identity\n", 3, $logfile); - return false; - } - $subs_raw = @file_get_contents($subs_file); - if (!is_string($subs_raw) || trim($subs_raw) === '') { - error_log("[mvlog] subscriptions empty, skipping push for $identity\n", 3, $logfile); - return false; - } - $subs = json_decode($subs_raw, true); - if (!is_array($subs) || count($subs) === 0) { - error_log("[mvlog] subscriptions invalid/empty, skipping push for $identity\n", 3, $logfile); - return false; - } - - // Read cache and check if already shown + // Read cache and check if already announced. $cache = []; if (is_file($cache_file)) { $cache = json_decode((string)@file_get_contents($cache_file), true) ?: []; @@ -133,24 +191,11 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul $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] push already sent for $identity (job=$job), skipping\n", 3, $logfile); + error_log("[mvlog] announcement already sent for $identity (job=$job), skipping\n", 3, $logfile); return false; } - // Resolve push config consistently (same source model as push_config.php) - $cfg = mvlog_load_push_config($mvlog_root); - if (!$cfg) { - error_log("[mvlog] push config not found/invalid, skipping push for $identity\n", 3, $logfile); - return false; - } - - $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); - return false; - } - - // Determine article title (used both in notification text and filtered URL). + // 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]); @@ -173,59 +218,98 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul 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) : ''); - $payload = json_encode([ - 'title' => $articleTitle, - 'body' => 'New unreliable article @ MVLog!', - 'url' => $articleUrl, - 'tag' => "mvlog-show-{$identity}", - ], JSON_UNESCAPED_UNICODE); + // Attempt web-push, but never let push failure prevent the journal/Listmonk campaign. + $pushOk = false; + $pushStatus = 'skipped'; + $cfgSource = 'none'; - // Call node send_push.js with subscriptions on stdin - $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"; + $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); - $descriptorspec = [ - 0 => ['pipe', 'r'], // stdin - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr - ]; + $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"; - $proc = proc_open($cmd, $descriptorspec, $pipes); - if (!is_resource($proc)) { - @unlink($tmp_push_config); - error_log("[mvlog] failed to start node send_push process for $identity\n", 3, $logfile); - return false; + $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); + } + } } - // Write subscriptions to stdin - fwrite($pipes[0], $subs_raw); - fclose($pipes[0]); + // 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); - $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]); - $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]); - - $rc = proc_close($proc); - @unlink($tmp_push_config); - - if ($rc !== 0) { - error_log("[mvlog] send_push failed for $identity rc=$rc stdout=" . trim((string)$stdout) . " stderr=" . trim((string)$stderr) . "\n", 3, $logfile); - return false; - } - - // On success, record in cache['shown'] + // 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'), ]; - // Atomic write: write to tmp then rename $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) { @@ -239,6 +323,6 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul return false; } - error_log("[mvlog] show-notification sent: identity=$identity job=$job (cfg=" . ($cfg['_source'] ?? 'unknown') . ")\n", 3, $logfile); + error_log("[mvlog] announcement processed: identity=$identity job=$job push=$pushStatus (cfg=$cfgSource)\n", 3, $logfile); return true; }