From 1ef7e2553f965db2b6f0a76a4e5379654d764750 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:51:17 +0800 Subject: [PATCH 1/4] fix: enforce single-owner explicit membership at every folder write sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One container could sit in two folders' containers[] via Import Everything bundles, hand-edited config, or a rename landing on a name already explicit elsewhere — and each surface then picked its own winner by processing order. fv3_dedupe_explicit_members() now runs at all three sinks that write folder maps: updateFolder (the saved folder wins), updateFolderIds (rename re-assertion, first-wins), and importAll (first folder in bundle order wins), so read sites never see a contested explicit claim. Closes #62 --- .../plugins/folder.view3/server/lib.php | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php index 9d3d7ed5..1e023779 100644 --- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php +++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php @@ -707,6 +707,31 @@ function syncContainerOrder(string $type): void { } } + // A container may be explicit in at most one folder (issue #62). $winnerId's list survives + // intact (the folder just saved wins); elsewhere ties resolve first-wins in key order. + function fv3_dedupe_explicit_members(array $folders, ?string $winnerId = null): array { + $claimed = []; + $winnerMembers = $winnerId !== null ? ($folders[$winnerId]['containers'] ?? null) : null; + if (is_array($winnerMembers)) { + foreach ($winnerMembers as $ct) { + if (is_string($ct)) { $claimed[$ct] = true; } + } + } + foreach ($folders as $fid => $folder) { + if ($fid === $winnerId || !is_array($folder['containers'] ?? null)) { continue; } + $kept = []; + foreach ($folder['containers'] as $ct) { + if (is_string($ct)) { + if (isset($claimed[$ct])) { continue; } + $claimed[$ct] = true; + } + $kept[] = $ct; + } + $folders[$fid]['containers'] = $kept; + } + return $folders; + } + function updateFolder(string $type, string $content, string $id = '') : void { global $configDir; if(!file_exists("$configDir/$type.json")) { createFile($type); if (empty($id)) $id = generateId(); } @@ -733,6 +758,7 @@ function updateFolder(string $type, string $content, string $id = '') : void { exit; } $fileData[$id] = $decoded; + $fileData = fv3_dedupe_explicit_members($fileData, $id); $path = "$configDir/$type.json"; fv3_atomic_write($path, json_encode($fileData)); } @@ -765,6 +791,8 @@ function updateFolderIds(string $type, string $data) : void { } } if ($changed) { + // A rename can land on a name already explicit elsewhere — re-assert single ownership (issue #62) + $fileData = fv3_dedupe_explicit_members($fileData); $path = "$configDir/$type.json"; fv3_atomic_write($path, json_encode($fileData)); } @@ -1278,7 +1306,9 @@ function importAll(string $json) : array { $clean[$fid] = $folder; } } - $data = $clean; + // Imported bundles (and folder.view2 exports) can hold one container in two + // folders — first folder in bundle order keeps it (issue #62) + $data = fv3_dedupe_explicit_members($clean); } $filename = $key === 'css_config' ? 'css-config.json' : "$key.json"; $path = "$configDir/$filename"; From 13fb790276dfc67f6ae8cf4c6c30ac9b356a8604 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:41:20 +0800 Subject: [PATCH 2/4] fix: fail closed on an unreadable config in the rename-resolution sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateFolderIds() read with the non-strict helper, so a corrupt config decoded as empty. That was previously inert — no folder id matched, so nothing was written — but the dedupe now rewrites every folder from that data, so the read matches updateFolder's strict abort. --- .../local/emhttp/plugins/folder.view3/server/lib.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php index 1e023779..52263b96 100644 --- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php +++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php @@ -768,7 +768,15 @@ function updateFolderIds(string $type, string $data) : void { if(!file_exists("$configDir/$type.json")) { return; } $updates = json_decode($data, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($updates)) { http_response_code(400); exit; } - $fileData = fv3_read_json("$configDir/$type.json"); + // Strict, like updateFolder: the dedupe below rewrites every folder, so a + // corrupt-as-empty read must abort rather than persist a pruned file + $fileData = fv3_read_json_strict("$configDir/$type.json"); + if ($fileData === null) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['error' => "$type.json is unreadable — refusing to save so existing folders are not wiped"]); + exit; + } $changed = false; foreach ($updates as $folderId => $patch) { if (!preg_match('/^[A-Za-z0-9+\/=]+$/', $folderId)) continue; From 5115c2736560711bac463a1c0ab0163cd9b485d9 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:22:39 +0800 Subject: [PATCH 3/4] fix: normalize the winner id before comparing it to folder keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP stores an all-digit folder id as an integer array key, so the strict comparison failed against the string id the caller passed. The winning folder was then treated as a loser and stripped of the very members claimed for it a few lines earlier — saving such a folder emptied its own container list. Both sides are compared as strings now, with the no-winner case kept explicit so a folder keyed by an empty string is still deduped. --- .../usr/local/emhttp/plugins/folder.view3/server/lib.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php index 52263b96..51abd4ef 100644 --- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php +++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php @@ -711,6 +711,9 @@ function syncContainerOrder(string $type): void { // intact (the folder just saved wins); elsewhere ties resolve first-wins in key order. function fv3_dedupe_explicit_members(array $folders, ?string $winnerId = null): array { $claimed = []; + // PHP stores an all-digit id as an int key, so compare canonical strings below — on === + // the winner would fail to match its own id and be stripped of the members claimed here + $winnerKey = $winnerId === null ? null : (string)$winnerId; $winnerMembers = $winnerId !== null ? ($folders[$winnerId]['containers'] ?? null) : null; if (is_array($winnerMembers)) { foreach ($winnerMembers as $ct) { @@ -718,7 +721,7 @@ function fv3_dedupe_explicit_members(array $folders, ?string $winnerId = null): } } foreach ($folders as $fid => $folder) { - if ($fid === $winnerId || !is_array($folder['containers'] ?? null)) { continue; } + if (($winnerKey !== null && (string)$fid === $winnerKey) || !is_array($folder['containers'] ?? null)) { continue; } $kept = []; foreach ($folder['containers'] as $ct) { if (is_string($ct)) { From 2dc493c94be40a8d4140d45fd7072b3f8b253ee7 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:29:33 +0800 Subject: [PATCH 4/4] fix: keep all-digit folder ids when importing a bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json_decode gives an all-digit folder key an integer type, so the import allowlist's is_string() check discarded that folder entirely — the restore then reported success with a folder missing. The key is validated as its canonical string instead, which keeps the alphanumeric allowlist that guards against ids breaking out of class/onclick attributes at render. --- .../usr/local/emhttp/plugins/folder.view3/server/lib.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php index 51abd4ef..795622a2 100644 --- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php +++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/lib.php @@ -1313,8 +1313,11 @@ function importAll(string $json) : array { if ($key === 'docker' || $key === 'vm') { $clean = []; foreach ($data as $fid => $folder) { - if (is_string($fid) && preg_match('#^[A-Za-z0-9]+$#D', $fid) && is_array($folder)) { - $clean[$fid] = $folder; + // (string) not is_string: json_decode gives an all-digit id an int key, which + // the old check dropped — silently discarding that folder and reporting success + $sid = (string)$fid; + if (preg_match('#^[A-Za-z0-9]+$#D', $sid) && is_array($folder)) { + $clean[$sid] = $folder; } } // Imported bundles (and folder.view2 exports) can hold one container in two