Compare commits
No commits in common. "598dd1592bf93ce347f641393d559c04d9d10acf" and "f29b0dee5e3a8226440c4ed49941c28411d3a7b6" have entirely different histories.
598dd1592b
...
f29b0dee5e
3 changed files with 73 additions and 230 deletions
|
|
@ -1,184 +0,0 @@
|
||||||
<?php
|
|
||||||
// examples/enable_show_local.php
|
|
||||||
// Web-server side handler to toggle "Show" for an MVLog job and send a web-push immediately if appropriate.
|
|
||||||
// This runs entirely on the web server and does not call the worker.
|
|
||||||
//
|
|
||||||
// Usage (POST): job=<job> 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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 <job> [--host <web-host>] [--remote-root <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
|
|
||||||
|
|
@ -23,11 +23,14 @@ from shlex import quote
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
MOVMAKER = ROOT / "movmaker.py"
|
MOVMAKER = ROOT / "movmaker.py"
|
||||||
INTRO_LOGO = ROOT / "img" / "moto_travel.png"
|
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"
|
STATE_NAME = ".movmaker-state.json"
|
||||||
LOCK_NAME = ".movmaker-lock"
|
LOCK_NAME = ".movmaker-lock"
|
||||||
ENABLED_NAME = ".movmaker-enabled"
|
ENABLED_NAME = ".movmaker-enabled"
|
||||||
PREVIEW_NAME = ".movmaker-preview"
|
PREVIEW_NAME = ".movmaker-preview"
|
||||||
HIDDEN_NAME = ".mvlog-hidden"
|
HIDDEN_NAME = ".mvlog-hidden"
|
||||||
|
NOTIFIED_CACHE = "cache/push_notifications.json"
|
||||||
INTRO_LOGO_ALPHA = "0.5"
|
INTRO_LOGO_ALPHA = "0.5"
|
||||||
MVLOG_WORKER_RECOMMENDED_INTERVAL_MINUTES = 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
|
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:
|
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:
|
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}"
|
job_dir = f"{remote_root}/in-dir/{job}"
|
||||||
state = read_state(host, job_dir)
|
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}")
|
full_requested = remote_file_exists(host, f"{job_dir}/{ENABLED_NAME}")
|
||||||
preview_requested = remote_file_exists(host, f"{job_dir}/{PREVIEW_NAME}")
|
preview_requested = remote_file_exists(host, f"{job_dir}/{PREVIEW_NAME}")
|
||||||
if not full_requested and not preview_requested:
|
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,
|
"output": output,
|
||||||
"generated_at": now(),
|
"generated_at": now(),
|
||||||
})
|
})
|
||||||
|
maybe_send_first_show_notification(host, remote_root, job_dir, job, new_state)
|
||||||
write_state(host, job_dir, new_state)
|
write_state(host, job_dir, new_state)
|
||||||
print(f"published: {job} -> {output}")
|
print(f"published: {job} -> {output}")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue