diff --git a/new.php b/new.php index 5d9c1dc..4a73e13 100644 --- a/new.php +++ b/new.php @@ -4,6 +4,7 @@ mvlog_require_login(); $config = require __DIR__ . "/config.php"; foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true); if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true); +const ARTICLE_ID_FILE = '.mvlog-id'; function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); } function ajax_json($data){ header('Content-Type: application/json; charset=UTF-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } function ascii_safe($s){ @@ -22,6 +23,96 @@ function slugify($s){ $s = ascii_safe($s); $s = strtolower(trim($s)); $s = preg_ function rrmdir($dir){ if(!is_dir($dir)) return; foreach(scandir($dir) as $f){ if($f==='.'||$f==='..') continue; $p="$dir/$f"; is_dir($p)?rrmdir($p):unlink($p);} rmdir($dir); } function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; } function safe_input_dir($base, $name){ $name = basename((string)$name); $path = realpath($base . '/' . $name); $root = realpath($base); if (!$path || !$root || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) || !is_dir($path)) throw new RuntimeException('Invalid input directory.'); return $path; } +function article_id_is_valid($id){ return is_string($id) && preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id); } +function generate_article_id(){ return gmdate('YmdHis') . bin2hex(random_bytes(8)); } +function read_article_id($dir){ + $idFile = rtrim((string)$dir, '/').'/'.ARTICLE_ID_FILE; + if (!is_file($idFile)) return ''; + $id = trim((string)file_get_contents($idFile)); + return article_id_is_valid($id) ? $id : ''; +} +function write_article_id($dir, $id){ + if (!article_id_is_valid($id)) throw new RuntimeException('Invalid article id.'); + $idFile = rtrim((string)$dir, '/').'/'.ARTICLE_ID_FILE; + $tmp = $idFile . '.tmp.' . bin2hex(random_bytes(4)); + if (file_put_contents($tmp, $id . "\n", LOCK_EX) === false) throw new RuntimeException('Cannot write article id.'); + chmod($tmp, 0664); + if (!rename($tmp, $idFile)) { @unlink($tmp); throw new RuntimeException('Cannot save article id.'); } +} +function ensure_article_id($dir, &$seenIds = null){ + $existing = read_article_id($dir); + if ($existing !== '' && (!is_array($seenIds) || empty($seenIds[$existing]))) { + if (is_array($seenIds)) $seenIds[$existing] = basename((string)$dir); + return $existing; + } + do { $id = generate_article_id(); } while (is_array($seenIds) && !empty($seenIds[$id])); + write_article_id($dir, $id); + if (is_array($seenIds)) $seenIds[$id] = basename((string)$dir); + return $id; +} +function article_index_signature($dirs){ + $names = array_map('basename', $dirs); + sort($names, SORT_STRING); + $parts = []; + foreach ($names as $name) $parts[] = $name; + return hash('sha256', implode("\n", $parts)); +} +function load_articles_index($base, $dirs = null){ + if ($dirs === null) $dirs = input_dirs($base); + $cacheFile = __DIR__ . '/cache/articles-index.json'; + $signature = article_index_signature($dirs); + $cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : []; + if (is_array($cache) + && ($cache['version'] ?? 0) === 1 + && ($cache['signature'] ?? '') === $signature + && is_array($cache['by_id'] ?? null) + && is_array($cache['by_dir'] ?? null) + && !in_array('', $cache['by_dir'], true)) { + return $cache; + } + + $byId = []; + $byDir = []; + foreach ($dirs as $dir) { + $name = basename((string)$dir); + $id = read_article_id($dir); + if (($id === '' || !empty($byId[$id])) && !is_dir($dir . '/.movmaker-lock')) { + $id = ensure_article_id($dir, $byId); + } + if ($id !== '' && empty($byId[$id])) $byId[$id] = $name; + $byDir[$name] = $id; + } + ksort($byId, SORT_STRING); + ksort($byDir, SORT_STRING); + $out = [ + 'version' => 1, + 'generated_at' => date('c'), + 'signature' => $signature, + 'by_id' => $byId, + 'by_dir' => $byDir, + ]; + file_put_contents($cacheFile, json_encode($out, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); + return $out; +} +function resolve_input_dir($base, $idOrDir, $articleIndex = null){ + $token = trim((string)$idOrDir); + if ($token === '') throw new RuntimeException('Invalid input directory.'); + if ($articleIndex === null) $articleIndex = load_articles_index($base); + if (article_id_is_valid($token)) { + $name = (string)($articleIndex['by_id'][$token] ?? ''); + if ($name !== '') return safe_input_dir($base, $name); + } + return safe_input_dir($base, $token); +} +function resolve_input_dir_from_request($base, $id, $dir, $articleIndex = null){ + $id = trim((string)$id); + $dir = trim((string)$dir); + if ($id !== '') { + try { return resolve_input_dir($base, $id, $articleIndex); } + catch (Throwable $e) { if ($dir !== '') return resolve_input_dir($base, $dir, $articleIndex); throw $e; } + } + return resolve_input_dir($base, $dir, $articleIndex); +} function read_data($dir){ $data = ['title'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $file = $dir . '/data.txt'; if (!is_file($file)) return $data; @@ -281,7 +372,7 @@ entries = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d != '.movmaker-lock'] for name in filenames: - if name in {'.movmaker-state.json', '.movmaker-enabled', '.movmaker-preview', '.mvlog-hidden'}: + if name in {'.movmaker-state.json', '.movmaker-enabled', '.movmaker-preview', '.mvlog-hidden', '.mvlog-id'}: continue path = os.path.join(dirpath, name) rel = os.path.relpath(path, root) @@ -300,6 +391,7 @@ function input_dir_info($dir){ $files = list_files($dir); return [ 'name' => basename($dir), + 'article_id' => ensure_article_id($dir), 'title' => $data['title'] ?: basename($dir), 'sort_date' => input_dir_date_from_text($data['date'] ?? '') ?? input_dir_date_from_media($dir), 'file_count' => count($files), @@ -316,7 +408,7 @@ function load_input_dir_cache($dirs){ $name = basename($dir); $sig = input_dir_signature($dir); $cached = $oldItems[$name] ?? null; - if (is_array($cached) && ($cached['signature'] ?? '') === $sig) { + if (is_array($cached) && ($cached['signature'] ?? '') === $sig && !empty($cached['article_id'])) { $items[$name] = $cached; } else { $items[$name] = input_dir_info($dir); @@ -380,10 +472,10 @@ function set_input_dir_preview($dir, $preview){ $stateFile = $dir . '/.movmaker-state.json'; $state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : []; if (!is_array($state)) $state = []; + $state['article_id'] = ensure_article_id($dir); $state['preview'] = (bool)$preview; $state['preview_updated_at'] = gmdate('c'); - file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . " -"); + file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"); chmod($stateFile, 0664); } function input_dir_visible($dir){ return !is_file($dir . '/.mvlog-hidden'); } @@ -392,10 +484,24 @@ function set_input_dir_visible($dir, $visible){ if ($visible) { if (is_file($path)) unlink($path); } else { file_put_contents($path, "hidden\n"); chmod($path, 0664); } } +function ensure_state_article_id($dir){ + $articleId = ensure_article_id($dir); + $stateFile = $dir . '/.movmaker-state.json'; + $state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : []; + if (!is_array($state)) $state = []; + if (($state['article_id'] ?? '') !== $articleId) { + $state['article_id'] = $articleId; + file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"); + chmod($stateFile, 0664); + } + return $articleId; +} function input_dir_state($dir){ $stateFile = $dir . '/.movmaker-state.json'; $state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : []; - return is_array($state) ? $state : []; + if (!is_array($state)) $state = []; + $state['article_id'] = ($state['article_id'] ?? '') ?: read_article_id($dir); + return $state; } function active_worker_jobs($config){ $jobs = []; @@ -507,6 +613,7 @@ function save_uploads($field, $dest, $allowed){ $msg = $_GET['msg'] ?? null; $err = null; $allowedMedia = ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm']; $allowedAudio = ['mp3','wav','m4a','aac','ogg','flac']; +$articleIndex = load_articles_index($config['uploads_dir']); try { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? 'create'; @@ -526,17 +633,19 @@ try { $slug = basename($dir); if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); } chmod($dir, 02775); + $articleId = ensure_article_id($dir); write_data($dir, $_POST); set_input_dir_enabled($dir, false); set_input_dir_preview($dir, false); set_input_dir_visible($dir, false); if (!empty($_POST['ajax'])) { - ajax_json(['ok'=>true, 'message'=>"Created movmaker input directory: in-dir/$slug", 'dir'=>$slug, 'tab'=>'edit']); + ajax_json(['ok'=>true, 'message'=>"Created movmaker input directory: in-dir/$slug", 'dir'=>$slug, 'id'=>$articleId, 'tab'=>'edit']); } header('Location: new.php?tab=edit'); exit; } elseif ($action === 'set_enabled') { - $dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? ''); + $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); + $articleId = ensure_state_article_id($dir); $enabledNow = !empty($_POST['enabled']); set_input_dir_enabled($dir, $enabledNow); if ($enabledNow) { @@ -555,13 +664,15 @@ try { 'visible' => input_dir_visible($dir) && $canShow, 'can_show' => $canShow, 'dir' => basename($dir), + 'id' => $articleId, 'message' => $message, ]); } header('Location: new.php?tab=edit&msg=' . rawurlencode($message)); exit; } elseif ($action === 'set_preview') { - $dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? ''); + $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); + $articleId = ensure_state_article_id($dir); $previewNow = !empty($_POST['preview']); set_input_dir_preview($dir, $previewNow); if ($previewNow) { set_input_dir_enabled($dir, false); set_input_dir_visible($dir, false); } @@ -577,13 +688,15 @@ try { 'visible' => input_dir_visible($dir) && $canShow, 'can_show' => $canShow, 'dir' => basename($dir), + 'id' => $articleId, 'message' => $message, ]); } header('Location: new.php?tab=edit&msg=' . rawurlencode($message)); exit; } elseif ($action === 'set_visible') { - $dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? ''); + $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); + $articleId = ensure_state_article_id($dir); $visibleNow = !empty($_POST['visible']); $state = input_dir_state($dir); $output = basename((string)($state['output'] ?? '')); @@ -608,24 +721,27 @@ try { 'visible' => $currentVisible, 'can_show' => $canShow, 'dir' => basename($dir), + 'id' => $articleId, 'message' => $message, ]); } header('Location: new.php?tab=edit&msg=' . rawurlencode($message)); exit; } elseif ($action === 'update') { - $dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? ''); + $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); + $articleId = ensure_state_article_id($dir); if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.'); write_data($dir, $_POST); save_uploads('media', $dir, $allowedMedia); save_uploads('audio', $dir, $allowedAudio); // Always regenerate map image(s) on Save changes. regenerate_input_dir_maps($dir, $config); - if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir)]); + if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir), 'id'=>$articleId]); header('Location: new.php?tab=edit'); exit; } elseif ($action === 'delete_file') { - $dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? ''); + $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); + $articleId = ensure_state_article_id($dir); if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.'); $file = basename((string)($_POST['file'] ?? '')); if (!editable_file($file) || !is_file($dir.'/'.$file)) throw new RuntimeException('Invalid file.'); @@ -648,7 +764,8 @@ try { header('Location: new.php?tab=videos'); exit; } elseif ($action === 'delete_dir') { - $dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? ''); + $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); + $articleId = ensure_state_article_id($dir); if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.'); $name = basename($dir); $stateFile = $dir . '/.movmaker-state.json'; @@ -676,6 +793,7 @@ try { $err = $e->getMessage(); } $dirs = input_dirs($config['uploads_dir']); +$articleIndex = load_articles_index($config['uploads_dir'], $dirs); $dirInfo = load_input_dir_cache($dirs); sort_input_dirs_by_metadata_date($dirs, $dirInfo); $orphanVideos = orphan_videos($config); @@ -716,8 +834,9 @@ $page = max(1, (int)($_GET['page'] ?? 1)); [$editPage, $editPages, $dirsPage, $dirsTotal] = paginate($dirs, $tab === 'edit' ? $page : 1); [$videoPage, $videoPages, $orphanVideosPage, $orphanVideosTotal] = paginate($orphanVideos, $tab === 'videos' ? $page : 1); $editName = $_GET['edit'] ?? ''; +$editId = $_GET['id'] ?? ''; $editDir = null; $editStatus = ''; $editRunning = false; $editData = ['title'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $editFiles = []; -if ($editName !== '') { try { $editDir = safe_input_dir($config['uploads_dir'], $editName); $editStatus = input_dir_status($editDir); $editRunning = input_dir_running($editDir); if (!$editRunning) { $editData = read_data($editDir); $editFiles = media_sort_files($editDir, list_files($editDir)); } } catch (Throwable $e) { $err = $e->getMessage(); } } +if ($editName !== '' || $editId !== '') { try { $editDir = resolve_input_dir_from_request($config['uploads_dir'], $editId, $editName, $articleIndex); $editStatus = input_dir_status($editDir); $editRunning = input_dir_running($editDir); if (!$editRunning) { $editData = read_data($editDir); $editFiles = media_sort_files($editDir, list_files($editDir)); } } catch (Throwable $e) { $err = $e->getMessage(); } } $runningJobs = active_worker_jobs($config); ?> Admin - <?=h($config['site_name'])?> @@ -730,6 +849,7 @@ $runningJobs = active_worker_jobs($config);
+