diff --git a/examples/enable_show_local.php b/examples/enable_show_local.php deleted file mode 100644 index 50e8ac9..0000000 --- a/examples/enable_show_local.php +++ /dev/null @@ -1,184 +0,0 @@ - show=1|0 -// Configure $MVLOG_ROOT and $PUSH_CONFIG below. - -// Configuration - update to match your server -$MVLOG_ROOT = '/var/www/html/mvlog'; -$PUSH_CONFIG_CANDIDATES = [ - "$MVLOG_ROOT/push.json", - getenv('HOME') !== false ? getenv('HOME') . '/.config/mvlog/push.json' : null, - '/etc/mvlog/push.json', -]; - -$LOGFILE = '/var/log/mvlog_notify.log'; - -$job = $_POST['job'] ?? ''; -$show = isset($_POST['show']) ? filter_var($_POST['show'], FILTER_VALIDATE_BOOLEAN) : true; - -// Basic validation for job name; adjust to your naming policy if needed -if (!preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job)) { - http_response_code(400); - echo 'invalid job'; - exit; -} - -$jobdir = "$MVLOG_ROOT/in-dir/$job"; -if (!is_dir($jobdir)) { - http_response_code(404); - echo 'job not found'; - exit; -} - -$enabled_file = "$jobdir/.movmaker-enabled"; -$hidden_file = "$jobdir/.mvlog-hidden"; -$subs_file = "$MVLOG_ROOT/cache/push_subscriptions.json"; -$cache_file = "$MVLOG_ROOT/cache/push_notifications.json"; - -// Find push config -$push_config = null; -foreach ($PUSH_CONFIG_CANDIDATES as $candidate) { - if (!$candidate) continue; - if (file_exists($candidate) && is_readable($candidate)) { - $push_config = $candidate; - break; - } -} - -if ($show) { - // Create marker file atomically - file_put_contents($enabled_file, date(DATE_ATOM) . PHP_EOL, LOCK_EX); - // Try to send push (non-fatal) - $sent = try_send_show_notification($job, $jobdir, $MVLOG_ROOT, $subs_file, $cache_file, $push_config, $LOGFILE); - if ($sent) { - echo 'enabled'; - } else { - echo 'enabled (no notification)'; - } -} else { - // Remove marker and do not touch notifications - @unlink($enabled_file); - echo 'disabled'; -} - -exit; - -function try_send_show_notification($job, $jobdir, $root, $subs_file, $cache_file, $push_config, $logfile) -{ - // Skip if hidden - if (file_exists($jobdir . '/.mvlog-hidden')) { - error_log("[mvlog] job $job hidden, skipping push\n", 3, $logfile); - return false; - } - - // Ensure we have subscriptions - if (!file_exists($subs_file)) { - error_log("[mvlog] subscriptions file missing, skipping push for $job\n", 3, $logfile); - return false; - } - $subs_raw = file_get_contents($subs_file); - if (!trim($subs_raw)) { - error_log("[mvlog] subscriptions empty, skipping push for $job\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 $job\n", 3, $logfile); - return false; - } - - // Read cache and check if already shown - $cache = []; - if (file_exists($cache_file)) { - $cache = json_decode(@file_get_contents($cache_file), true) ?: []; - } - $shown = isset($cache['shown']) && is_array($cache['shown']) ? $cache['shown'] : []; - if (isset($shown[$job])) { - error_log("[mvlog] push already sent for $job, skipping\n", 3, $logfile); - return false; - } - - // Ensure push config present - if (!$push_config) { - error_log("[mvlog] push config not found, skipping push for $job\n", 3, $logfile); - return false; - } - - // Determine title (prefer output filename from state file) - $title = preg_replace('/_+/', ' ', $job); - $title = ucwords($title); - $state_file = $jobdir . '/.movmaker-state.json'; - if (file_exists($state_file)) { - $state = json_decode(@file_get_contents($state_file), true) ?: []; - if (!empty($state['output']) && is_string($state['output'])) { - $out = pathinfo($state['output'], PATHINFO_FILENAME); - if ($out) { - $title = preg_replace('/_+/', ' ', $out); - $title = ucwords($title); - } - } - } - - $payload = json_encode([ - 'title' => 'Show enabled', - 'body' => $title, - 'url' => "https://bubulescu.org/mvlog/?job=$job", - 'tag' => "mvlog-show-$job", - ], JSON_UNESCAPED_UNICODE); - - // Call node send_push.js with subscriptions on stdin - $send_script = escapeshellarg($root . '/send_push.js'); - $push_config_esc = escapeshellarg($push_config); - $payload_esc = escapeshellarg($payload); - $cmd = "node $send_script $push_config_esc $payload_esc"; - - $descriptorspec = [ - 0 => ['pipe', 'r'], // stdin - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr - ]; - - $proc = proc_open($cmd, $descriptorspec, $pipes); - if (!is_resource($proc)) { - error_log("[mvlog] failed to start node send_push process for $job\n", 3, $logfile); - return false; - } - - // Write subscriptions to stdin - fwrite($pipes[0], $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) { - error_log("[mvlog] send_push failed for $job rc=$rc stdout=" . trim($stdout) . " stderr=" . trim($stderr) . "\n", 3, $logfile); - return false; - } - - // On success, record in cache['shown'] - if (!is_array($cache)) $cache = []; - if (!isset($cache['shown']) || !is_array($cache['shown'])) $cache['shown'] = []; - $cache['shown'][$job] = ['sent_at' => date(DATE_ATOM)]; - - // 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) { - error_log("[mvlog] failed to write cache temp file for $job\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 $job\n", 3, $logfile); - return false; - } - - error_log("[mvlog] show-notification sent: $job\n", 3, $logfile); - return true; -} diff --git a/hooks/mvlog-authorized-notify.sh b/hooks/mvlog-authorized-notify.sh deleted file mode 100755 index 485d208..0000000 --- a/hooks/mvlog-authorized-notify.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# mvlog-authorized-notify.sh - forced-command wrapper for a web-server SSH key. -# Install in worker user's authorized_keys as: -# command="/home/hbrain/source/movmaker/hooks/mvlog-authorized-notify.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAA... comment -# -# The wrapper only allows a very small set of commands originating from the web server: -# python3 /home/hbrain/source/movmaker/mvlog_worker.py --notify-job [--host ] [--remote-root ] -# It validates the job token (simple filename-safe characters) and restricts remote-root to the configured path. - -WORKER_MVLOG_PATH="/home/hbrain/source/movmaker/mvlog_worker.py" -ALLOWED_REMOTE_ROOT="/var/www/html/mvlog" - -cmd="${SSH_ORIGINAL_COMMAND:-}" - -# Expected parts regex; allow quoted or unquoted simple tokens for job/host/root -escaped_path=$(printf '%s' "$WORKER_MVLOG_PATH" | sed 's/[\/&]/\\&/g') -regex="^python3[[:space:]]+${escaped_path}[[:space:]]+--notify-job[[:space:]]+'?([A-Za-z0-9._-]+)'?([[:space:]]+--host[[:space:]]+'?([^'[:space:]]+)'?)?([[:space:]]+--remote-root[[:space:]]+'?([^'[:space:]]+)'?)?.*$" - -if [[ $cmd =~ $regex ]]; then - job="${BASH_REMATCH[1]}" - host="${BASH_REMATCH[3]:-}" - remote_root="${BASH_REMATCH[5]:-}" - - # Validate simple job token again - if ! [[ $job =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "invalid job token" >&2 - exit 1 - fi - - if [ -n "$remote_root" ] && [ "$remote_root" != "$ALLOWED_REMOTE_ROOT" ]; then - echo "remote_root not allowed" >&2 - exit 1 - fi - - # Run the notify command with validated pieces. If host omitted, use local hostname. - if [ -n "$host" ]; then - exec python3 "$WORKER_MVLOG_PATH" --notify-job "$job" --host "$host" --remote-root "$ALLOWED_REMOTE_ROOT" - else - exec python3 "$WORKER_MVLOG_PATH" --notify-job "$job" --host "$(hostname -f)" --remote-root "$ALLOWED_REMOTE_ROOT" - fi -fi - -echo "Unauthorized SSH_ORIGINAL_COMMAND" >&2 -exit 2 diff --git a/mvlog_worker.py b/mvlog_worker.py index a9dc23d..ad94c86 100755 --- a/mvlog_worker.py +++ b/mvlog_worker.py @@ -23,11 +23,14 @@ from shlex import quote ROOT = Path(__file__).resolve().parent MOVMAKER = ROOT / "movmaker.py" INTRO_LOGO = ROOT / "img" / "moto_travel.png" +PUSH_SENDER = ROOT / "send_push.js" +PUSH_CONFIG = Path.home() / ".config" / "mvlog" / "push.json" STATE_NAME = ".movmaker-state.json" LOCK_NAME = ".movmaker-lock" ENABLED_NAME = ".movmaker-enabled" PREVIEW_NAME = ".movmaker-preview" HIDDEN_NAME = ".mvlog-hidden" +NOTIFIED_CACHE = "cache/push_notifications.json" INTRO_LOGO_ALPHA = "0.5" MVLOG_WORKER_RECOMMENDED_INTERVAL_MINUTES = 5 @@ -168,6 +171,74 @@ def copy_job_from_remote(host: str, job_dir: str, local_input: Path) -> None: raise +def send_push_notification(host: str, remote_root: str, output: str) -> bool: + if not PUSH_SENDER.exists() or not PUSH_CONFIG.exists(): + return False + res = ssh(host, f"cat {remote_q(remote_root + '/cache/push_subscriptions.json')} 2>/dev/null", check=False) + if res.returncode != 0 or not res.stdout.strip(): + return False + title = output.rsplit('.', 1)[0].replace('_', ' ').title() + payload = json.dumps({ + "title": "New MVLog video", + "body": title, + "url": "https://bubulescu.org/mvlog/", + "tag": f"mvlog-{output}", + }, ensure_ascii=False) + proc = subprocess.run(["node", str(PUSH_SENDER), str(PUSH_CONFIG), payload], input=res.stdout, text=True, capture_output=True, check=False) + if proc.stdout.strip(): + print(proc.stdout.strip()) + if proc.stderr.strip(): + print(proc.stderr.strip(), file=sys.stderr) + return proc.returncode == 0 + + +def maybe_send_first_show_notification(host: str, remote_root: str, job_dir: str, job: str, state: dict | None = None) -> None: + """ + Send a web-push notification the first time the 'Show' checkbox is set for a job. + We record notifications in cache/push_notifications.json under the 'shown' map keyed by job + to ensure each job triggers only once when Show is enabled. + """ + # Don't notify for hidden jobs + if remote_file_exists(host, f"{job_dir}/{HIDDEN_NAME}"): + return + + cache_path = f"{remote_root}/{NOTIFIED_CACHE}" + cache = read_remote_json(host, cache_path) + shown = cache.get("shown") if isinstance(cache.get("shown"), dict) else {} + + # If we've already recorded that Show was sent for this job, skip + if job in shown: + return + + # Notify when the enabled marker exists (Show checked) + if not remote_file_exists(host, f"{job_dir}/{ENABLED_NAME}"): + return + + # Determine a title/url for the notification. Prefer existing output filename if present. + state = state if state is not None else read_state(host, job_dir) + output = state.get("output") if isinstance(state.get("output"), str) else "" + title = output.rsplit('.', 1)[0].replace('_', ' ').title() if output else job.replace('_', ' ').title() + payload = json.dumps({ + "title": "Show enabled", + "body": title, + "url": f"https://bubulescu.org/mvlog/?job={job}", + "tag": f"mvlog-show-{job}", + }, ensure_ascii=False) + + # Gather subscriptions and send + res = ssh(host, f"cat {remote_q(remote_root + '/cache/push_subscriptions.json')} 2>/dev/null", check=False) + if res.returncode != 0 or not res.stdout.strip(): + return + proc = subprocess.run(["node", str(PUSH_SENDER), str(PUSH_CONFIG), payload], input=res.stdout, text=True, capture_output=True, check=False) + if proc.stdout.strip(): + print(proc.stdout.strip()) + if proc.stderr.strip(): + print(proc.stderr.strip(), file=sys.stderr) + if proc.returncode == 0: + shown[job] = {"sent_at": now()} + cache["shown"] = shown + write_remote_json(host, cache_path, cache) + print(f"show-notification sent: {job}") def publish_output(host: str, remote_root: str, local_mp4: Path, old_output: str | None) -> str: @@ -221,6 +292,7 @@ def render_job(local_input: Path, local_out: Path, extra_args: list[str]) -> Pat def process_job(host: str, remote_root: str, job: str, work_dir: Path, extra_args: list[str], force: bool) -> None: job_dir = f"{remote_root}/in-dir/{job}" state = read_state(host, job_dir) + maybe_send_first_show_notification(host, remote_root, job_dir, job, state) full_requested = remote_file_exists(host, f"{job_dir}/{ENABLED_NAME}") preview_requested = remote_file_exists(host, f"{job_dir}/{PREVIEW_NAME}") if not full_requested and not preview_requested: @@ -314,6 +386,7 @@ def process_job(host: str, remote_root: str, job: str, work_dir: Path, extra_arg "output": output, "generated_at": now(), }) + maybe_send_first_show_notification(host, remote_root, job_dir, job, new_state) write_state(host, job_dir, new_state) print(f"published: {job} -> {output}") return