diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/de.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/de.json
index bc9eb078..881d213b 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/de.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/de.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "Ordner \"$1\" konnte nicht gelöscht werden: $2",
+ "save-folder-failed": "Der Ordner konnte nicht gespeichert werden: $1",
+ "clear-folders-failed": "Diese Ordner konnten nicht gelöscht werden: $1",
+ "defaults-not-applied": "Die Standardwerte wurden auf diese Ordner nicht angewendet: $1",
+ "import-folders-failed": "Diese Ordner konnten nicht importiert werden: $1",
+ "folder-load-failed": "Der Ordner konnte nicht geladen werden, daher ist Speichern deaktiviert. Lade die Seite neu und versuche es erneut.",
+ "folder-not-found": "Dieser Ordner existiert nicht mehr.",
+ "folder-data-load-failed": "Ordnerdaten konnten nicht geladen werden. Bitte lade die Seite neu.",
+ "importing-folders": "Ordner werden importiert…",
+ "import-failed": "Import fehlgeschlagen: $1",
+ "folder-order-load-failed": "Die Ordnerreihenfolge konnte nicht geladen werden. Lade die Seite neu.",
+ "reset-order-failed": "Die Ordnerreihenfolge konnte nicht zurückgesetzt werden. Versuche es erneut.",
+ "dashboard-settings-load-failed": "Die Dashboard-Einstellungen konnten nicht geladen werden, daher werden die Standardlayouts angezeigt. Lade die Seite neu.",
+ "invalid-folder-export": "Diese Datei ist kein Ordnerexport.",
+ "order-sync-failed": "Gespeichert, aber die Docker-Startreihenfolge konnte nicht aktualisiert werden: $1",
+ "docker-folders-load-failed": "Die Docker-Ordnerdaten konnten nicht geladen werden. Lade die Seite neu.",
+ "vm-folders-load-failed": "Die VM-Ordnerdaten konnten nicht geladen werden. Lade die Seite neu.",
+ "container-details-load-failed": "Die Container-Details konnten nicht geladen werden. Lade die Seite neu.",
+ "vm-details-load-failed": "Die VM-Details konnten nicht geladen werden. Lade die Seite neu.",
+ "docker-render-failed": "FolderView3: Die Docker-Ordner konnten nicht angezeigt werden — Details stehen in der Browser-Konsole.",
+ "vm-render-failed": "FolderView3: Die VM-Ordner konnten nicht angezeigt werden — Details stehen in der Browser-Konsole.",
+ "user-script-failed": "Das User-Script konnte nicht ausgeführt werden — prüfe, ob das Plugin User Scripts installiert ist und das Script existiert.",
+ "container-action-failed": "Die Container-Aktion \"$1\" ist fehlgeschlagen. Prüfe die Verbindung zum Server.",
+ "vm-action-failed": "Die VM-Aktion \"$1\" ist fehlgeschlagen. Prüfe die Verbindung zum Server.",
"order": "Reihenfolge:",
"order-tooltip": "Ziehe Zeilen per Drag & Drop zum Neuordnen. Aktiviere „Eingeschlossen“, um einen Container hinzuzufügen, „Vorschau ausblenden“, um ihn aus der Vorschauzeile auszuschließen. Regex-Bearbeitung setzt manuelle Auswahl zurück.",
"table-name": "Name",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/en.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/en.json
index 9d267521..e6456918 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/en.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/en.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "Could not delete folder \"$1\": $2",
+ "save-folder-failed": "Could not save the folder: $1",
+ "clear-folders-failed": "These folders could not be deleted: $1",
+ "defaults-not-applied": "Defaults were not applied to these folders: $1",
+ "import-folders-failed": "These folders could not be imported: $1",
+ "folder-load-failed": "This folder could not be loaded, so saving is disabled. Reload the page and try again.",
+ "folder-not-found": "This folder no longer exists.",
+ "folder-data-load-failed": "Could not load folder data. Try refreshing the page.",
+ "importing-folders": "Importing folders…",
+ "import-failed": "Import failed: $1",
+ "folder-order-load-failed": "Could not load folder order. Try refreshing the page.",
+ "reset-order-failed": "Could not reset the folder order. Try again.",
+ "dashboard-settings-load-failed": "Could not load Dashboard settings, so the default layouts are shown. Try refreshing the page.",
+ "invalid-folder-export": "This file is not a folder export.",
+ "order-sync-failed": "Saved, but the Docker start order could not be updated: $1",
+ "docker-folders-load-failed": "Could not load Docker folder data. Try refreshing the page.",
+ "vm-folders-load-failed": "Could not load VM folder data. Try refreshing the page.",
+ "container-details-load-failed": "Could not load container details. Try refreshing the page.",
+ "vm-details-load-failed": "Could not load VM details. Try refreshing the page.",
+ "docker-render-failed": "FolderView3: Docker folder rendering failed — see the browser console for details.",
+ "vm-render-failed": "FolderView3: VM folder rendering failed — see the browser console for details.",
+ "user-script-failed": "Could not run the user script — check the User Scripts plugin is installed and the script exists.",
+ "container-action-failed": "The container action \"$1\" failed. Check your server connection.",
+ "vm-action-failed": "The VM action \"$1\" failed. Check your server connection.",
"order": "Order:",
"order-tooltip": "Drag and drop rows to reorder containers. Check 'Included' to add a container, 'Hide Preview' to exclude it from the preview row. Editing the regex will reset manual selections.",
"table-name": "Name",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/es.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/es.json
index 411a2e8d..577dbf78 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/es.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/es.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "No se pudo borrar la carpeta \"$1\": $2",
+ "save-folder-failed": "No se pudo guardar la carpeta: $1",
+ "clear-folders-failed": "No se pudieron borrar estas carpetas: $1",
+ "defaults-not-applied": "No se aplicaron los valores predeterminados a estas carpetas: $1",
+ "import-folders-failed": "No se pudieron importar estas carpetas: $1",
+ "folder-load-failed": "No se pudo cargar la carpeta, así que guardar está desactivado. Recarga la página e inténtalo de nuevo.",
+ "folder-not-found": "Esta carpeta ya no existe.",
+ "folder-data-load-failed": "No se pudieron cargar los datos de las carpetas. Recarga la página.",
+ "importing-folders": "Importando carpetas…",
+ "import-failed": "Error al importar: $1",
+ "folder-order-load-failed": "No se pudo cargar el orden de las carpetas. Recarga la página.",
+ "reset-order-failed": "No se pudo restablecer el orden de las carpetas. Inténtalo de nuevo.",
+ "dashboard-settings-load-failed": "No se pudo cargar la configuración del panel, así que se muestran los diseños predeterminados. Recarga la página.",
+ "invalid-folder-export": "Este archivo no es una exportación de carpetas.",
+ "order-sync-failed": "Guardado, pero no se pudo actualizar el orden de inicio de Docker: $1",
+ "docker-folders-load-failed": "No se pudieron cargar los datos de las carpetas de Docker. Recarga la página.",
+ "vm-folders-load-failed": "No se pudieron cargar los datos de las carpetas de VM. Recarga la página.",
+ "container-details-load-failed": "No se pudieron cargar los detalles de los contenedores. Recarga la página.",
+ "vm-details-load-failed": "No se pudieron cargar los detalles de las VM. Recarga la página.",
+ "docker-render-failed": "FolderView3: no se pudieron mostrar las carpetas de Docker — consulta la consola del navegador para ver los detalles.",
+ "vm-render-failed": "FolderView3: no se pudieron mostrar las carpetas de VM — consulta la consola del navegador para ver los detalles.",
+ "user-script-failed": "No se pudo ejecutar el script de usuario — comprueba que el plugin User Scripts está instalado y que el script existe.",
+ "container-action-failed": "Falló la acción de contenedor \"$1\". Comprueba la conexión con el servidor.",
+ "vm-action-failed": "Falló la acción de VM \"$1\". Comprueba la conexión con el servidor.",
"order": "Orden:",
"order-tooltip": "Arrastra y suelta filas para reordenar contenedores. Marca \"Incluido\" para agregar un contenedor, \"Ocultar vista previa\" para excluirlo de la fila de vista previa. Editar el regex restablecerá las selecciones manuales.",
"table-name": "Nombre",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/fr.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/fr.json
index 2f321d7e..7b8b1880 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/fr.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/fr.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "Impossible de supprimer le dossier \"$1\" : $2",
+ "save-folder-failed": "Impossible d'enregistrer le dossier : $1",
+ "clear-folders-failed": "Ces dossiers n'ont pas pu être supprimés : $1",
+ "defaults-not-applied": "Les paramètres par défaut n'ont pas été appliqués à ces dossiers : $1",
+ "import-folders-failed": "Ces dossiers n'ont pas pu être importés : $1",
+ "folder-load-failed": "Le dossier n'a pas pu être chargé, l'enregistrement est donc désactivé. Rechargez la page et réessayez.",
+ "folder-not-found": "Ce dossier n'existe plus.",
+ "folder-data-load-failed": "Impossible de charger les données des dossiers. Rechargez la page.",
+ "importing-folders": "Importation des dossiers…",
+ "import-failed": "Échec de l'importation : $1",
+ "folder-order-load-failed": "Impossible de charger l'ordre des dossiers. Rechargez la page.",
+ "reset-order-failed": "Impossible de réinitialiser l'ordre des dossiers. Réessayez.",
+ "dashboard-settings-load-failed": "Impossible de charger les paramètres du tableau de bord, les dispositions par défaut sont donc affichées. Rechargez la page.",
+ "invalid-folder-export": "Ce fichier n'est pas un export de dossiers.",
+ "order-sync-failed": "Enregistré, mais l'ordre de démarrage Docker n'a pas pu être mis à jour : $1",
+ "docker-folders-load-failed": "Impossible de charger les données des dossiers Docker. Rechargez la page.",
+ "vm-folders-load-failed": "Impossible de charger les données des dossiers VM. Rechargez la page.",
+ "container-details-load-failed": "Impossible de charger les détails des conteneurs. Rechargez la page.",
+ "vm-details-load-failed": "Impossible de charger les détails des VM. Rechargez la page.",
+ "docker-render-failed": "FolderView3 : l'affichage des dossiers Docker a échoué — consultez la console du navigateur pour plus de détails.",
+ "vm-render-failed": "FolderView3 : l'affichage des dossiers VM a échoué — consultez la console du navigateur pour plus de détails.",
+ "user-script-failed": "Impossible d'exécuter le script utilisateur — vérifiez que le plugin User Scripts est installé et que le script existe.",
+ "container-action-failed": "L'action de conteneur \"$1\" a échoué. Vérifiez la connexion au serveur.",
+ "vm-action-failed": "L'action de VM \"$1\" a échoué. Vérifiez la connexion au serveur.",
"order": "Ordre :",
"order-tooltip": "Glissez-déposez les lignes pour réordonner les conteneurs. Cochez « Inclus » pour ajouter un conteneur, « Masquer l'aperçu » pour l'exclure de la barre d'aperçu. Modifier le regex réinitialise les sélections manuelles.",
"table-name": "Nom",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/it.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/it.json
index 773db1c7..ea10cd6c 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/it.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/it.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "Impossibile rimuovere la cartella \"$1\": $2",
+ "save-folder-failed": "Impossibile salvare la cartella: $1",
+ "clear-folders-failed": "Impossibile rimuovere queste cartelle: $1",
+ "defaults-not-applied": "Le impostazioni predefinite non sono state applicate a queste cartelle: $1",
+ "import-folders-failed": "Impossibile importare queste cartelle: $1",
+ "folder-load-failed": "Impossibile caricare la cartella, quindi il salvataggio è disattivato. Ricarica la pagina e riprova.",
+ "folder-not-found": "Questa cartella non esiste più.",
+ "folder-data-load-failed": "Impossibile caricare i dati delle cartelle. Ricarica la pagina.",
+ "importing-folders": "Importazione delle cartelle…",
+ "import-failed": "Importazione non riuscita: $1",
+ "folder-order-load-failed": "Impossibile caricare l'ordine delle cartelle. Ricarica la pagina.",
+ "reset-order-failed": "Impossibile reimpostare l'ordine delle cartelle. Riprova.",
+ "dashboard-settings-load-failed": "Impossibile caricare le impostazioni della dashboard, quindi vengono mostrati i layout predefiniti. Ricarica la pagina.",
+ "invalid-folder-export": "Questo file non è un'esportazione di cartelle.",
+ "order-sync-failed": "Salvato, ma non è stato possibile aggiornare l'ordine di avvio di Docker: $1",
+ "docker-folders-load-failed": "Impossibile caricare i dati delle cartelle Docker. Ricarica la pagina.",
+ "vm-folders-load-failed": "Impossibile caricare i dati delle cartelle VM. Ricarica la pagina.",
+ "container-details-load-failed": "Impossibile caricare i dettagli dei container. Ricarica la pagina.",
+ "vm-details-load-failed": "Impossibile caricare i dettagli delle VM. Ricarica la pagina.",
+ "docker-render-failed": "FolderView3: impossibile visualizzare le cartelle Docker — controlla la console del browser per i dettagli.",
+ "vm-render-failed": "FolderView3: impossibile visualizzare le cartelle VM — controlla la console del browser per i dettagli.",
+ "user-script-failed": "Impossibile eseguire lo script utente — verifica che il plugin User Scripts sia installato e che lo script esista.",
+ "container-action-failed": "L'azione sul container \"$1\" non è riuscita. Verifica la connessione al server.",
+ "vm-action-failed": "L'azione sulla VM \"$1\" non è riuscita. Verifica la connessione al server.",
"order": "Ordine:",
"order-tooltip": "Trascina e rilascia le righe per riordinare i container. Spunta \"Incluso\" per aggiungere un container, \"Nascondi anteprima\" per escluderlo dalla riga di anteprima. Modificare il regex ripristina le selezioni manuali.",
"table-name": "Nome",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/pl.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/pl.json
index 66d0bd74..f931dcbb 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/pl.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/pl.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "Nie można usunąć folderu \"$1\": $2",
+ "save-folder-failed": "Nie można zapisać folderu: $1",
+ "clear-folders-failed": "Nie można usunąć tych folderów: $1",
+ "defaults-not-applied": "Nie zastosowano ustawień domyślnych do tych folderów: $1",
+ "import-folders-failed": "Nie można zaimportować tych folderów: $1",
+ "folder-load-failed": "Nie można wczytać folderu, więc zapisywanie jest wyłączone. Odśwież stronę i spróbuj ponownie.",
+ "folder-not-found": "Ten folder już nie istnieje.",
+ "folder-data-load-failed": "Nie można wczytać danych folderów. Odśwież stronę.",
+ "importing-folders": "Importowanie folderów…",
+ "import-failed": "Import nie powiódł się: $1",
+ "folder-order-load-failed": "Nie można wczytać kolejności folderów. Odśwież stronę.",
+ "reset-order-failed": "Nie można zresetować kolejności folderów. Spróbuj ponownie.",
+ "dashboard-settings-load-failed": "Nie można wczytać ustawień pulpitu, więc wyświetlane są układy domyślne. Odśwież stronę.",
+ "invalid-folder-export": "Ten plik nie jest eksportem folderów.",
+ "order-sync-failed": "Zapisano, ale nie można zaktualizować kolejności uruchamiania Dockera: $1",
+ "docker-folders-load-failed": "Nie można wczytać danych folderów Dockera. Odśwież stronę.",
+ "vm-folders-load-failed": "Nie można wczytać danych folderów VM. Odśwież stronę.",
+ "container-details-load-failed": "Nie można wczytać szczegółów kontenerów. Odśwież stronę.",
+ "vm-details-load-failed": "Nie można wczytać szczegółów maszyn wirtualnych. Odśwież stronę.",
+ "docker-render-failed": "FolderView3: nie udało się wyświetlić folderów Dockera — szczegóły znajdziesz w konsoli przeglądarki.",
+ "vm-render-failed": "FolderView3: nie udało się wyświetlić folderów VM — szczegóły znajdziesz w konsoli przeglądarki.",
+ "user-script-failed": "Nie można uruchomić skryptu użytkownika — sprawdź, czy wtyczka User Scripts jest zainstalowana i czy skrypt istnieje.",
+ "container-action-failed": "Akcja kontenera \"$1\" nie powiodła się. Sprawdź połączenie z serwerem.",
+ "vm-action-failed": "Akcja maszyny wirtualnej \"$1\" nie powiodła się. Sprawdź połączenie z serwerem.",
"order": "Kolejność:",
"order-tooltip": "Przeciągnij i upuść wiersze, aby zmienić kolejność kontenerów. Zaznacz „Dołączony”, aby dodać kontener, „Ukryj podgląd”, aby wykluczyć go z wiersza podglądu. Edycja regex resetuje ręczne wybory.",
"table-name": "Nazwa",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/script.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/script.php
index 9b23d636..afcc9875 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/script.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/script.php
@@ -14,16 +14,25 @@
\ No newline at end of file
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/zh.json b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/zh.json
index f3354ce5..b22baaad 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/zh.json
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/langs/zh.json
@@ -145,6 +145,30 @@
"delete-folder": "Delete folder",
"delete-folder-confirm-title": "Delete folder?",
"delete-folder-confirm-text": "This will permanently delete the folder \"$1\". The containers/VMs inside are NOT deleted — they return to the main list.",
+ "delete-folder-failed": "无法删除文件夹“$1”:$2",
+ "save-folder-failed": "无法保存文件夹:$1",
+ "clear-folders-failed": "以下文件夹无法删除:$1",
+ "defaults-not-applied": "以下文件夹未能应用默认设置:$1",
+ "import-folders-failed": "以下文件夹无法导入:$1",
+ "folder-load-failed": "无法加载此文件夹,因此已禁用保存。请刷新页面后重试。",
+ "folder-not-found": "此文件夹已不存在。",
+ "folder-data-load-failed": "无法加载文件夹数据。请刷新页面。",
+ "importing-folders": "正在导入文件夹…",
+ "import-failed": "导入失败:$1",
+ "folder-order-load-failed": "无法加载文件夹顺序。请刷新页面。",
+ "reset-order-failed": "无法重置文件夹顺序。请重试。",
+ "dashboard-settings-load-failed": "无法加载仪表板设置,因此显示默认布局。请刷新页面。",
+ "invalid-folder-export": "此文件不是文件夹导出文件。",
+ "order-sync-failed": "已保存,但无法更新 Docker 启动顺序:$1",
+ "docker-folders-load-failed": "无法加载 Docker 文件夹数据。请刷新页面。",
+ "vm-folders-load-failed": "无法加载虚拟机文件夹数据。请刷新页面。",
+ "container-details-load-failed": "无法加载容器详情。请刷新页面。",
+ "vm-details-load-failed": "无法加载虚拟机详情。请刷新页面。",
+ "docker-render-failed": "FolderView3:Docker 文件夹渲染失败——详情请查看浏览器控制台。",
+ "vm-render-failed": "FolderView3:虚拟机文件夹渲染失败——详情请查看浏览器控制台。",
+ "user-script-failed": "无法运行用户脚本——请检查是否已安装 User Scripts 插件且脚本存在。",
+ "container-action-failed": "容器操作“$1”失败。请检查与服务器的连接。",
+ "vm-action-failed": "虚拟机操作“$1”失败。请检查与服务器的连接。",
"order": "排序:",
"order-tooltip": "拖放行以重新排列容器。勾选\"已包含\"添加容器,\"隐藏预览\"从预览行中排除。编辑正则表达式将重置手动选择。",
"table-name": "名称",
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/csstool.js b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/csstool.js
index a956ed71..a8dc86d9 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/csstool.js
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/csstool.js
@@ -894,12 +894,12 @@
window._fv3ThemeUpdateCache[theme.name] = hasUpdate ? 'update' : 'current';
try { localStorage.setItem('fv3_theme_update_cache', JSON.stringify(window._fv3ThemeUpdateCache)); } catch(e) {}
if (hasUpdate) {
- checkEl.innerHTML = ' update ready ';
+ checkEl.innerHTML = ' ' + escapeAttr(fv3I18nOr('update-ready', 'update ready')) + ' ';
const card = checkEl.closest('.fv3-theme-card');
const updateBtn = card?.querySelector('.fv3-theme-update');
if (updateBtn) updateBtn.style.display = '';
} else {
- checkEl.innerHTML = ' up-to-date ';
+ checkEl.innerHTML = ' ' + escapeAttr(fv3I18nOr('up-to-date', 'up-to-date')) + ' ';
}
} catch (e) {
console.warn('[FV3] Update check error for', theme.name, e);
@@ -930,7 +930,7 @@
`;
defaultCard.querySelector('.fv3-theme-activate')?.addEventListener('click', async () => {
for (const t of themes.filter(t => t.enabled && t.isDir && t.source)) {
- await postForm(API + '/toggle_theme.php', { entry: t.entry, enable: 'false' });
+ if (!(await setThemeEnabled(t.entry, false))) break;
}
loadThemes();
});
@@ -966,15 +966,15 @@
? 'Disable '
: 'Enable '}
${repo ? ' ' : ''}
- apply update
+ ${escapeAttr(fv3I18nOr('apply-update', 'apply update'))}
`;
card.querySelector('.fv3-theme-activate')?.addEventListener('click', async () => {
- await postForm(API + '/toggle_theme.php', { entry: theme.entry, enable: 'true' });
+ await setThemeEnabled(theme.entry, true);
loadThemes();
});
card.querySelector('.fv3-theme-disable')?.addEventListener('click', async () => {
- await postForm(API + '/toggle_theme.php', { entry: theme.entry, enable: 'false' });
+ await setThemeEnabled(theme.entry, false);
loadThemes();
});
card.querySelector('.fv3-theme-update')?.addEventListener('click', async () => {
@@ -1019,7 +1019,7 @@
if (githubThemes.length > 0) {
const btnBar = document.createElement('div');
btnBar.className = 'fv3-theme-button-bar';
- btnBar.innerHTML = 'Check for Updates Update All ';
+ btnBar.innerHTML = '' + escapeAttr(fv3I18nOr('check-for-updates', 'Check for Updates')) + ' ' + escapeAttr(fv3I18nOr('update-all', 'Update All')) + ' ';
btnBar.querySelector('.fv3-theme-check-all').addEventListener('click', () => {
runThemeUpdateCheck();
});
@@ -1082,11 +1082,11 @@
`;
card.querySelector('.fv3-theme-activate')?.addEventListener('click', async () => {
- await postForm(API + '/toggle_theme.php', { entry: theme.entry, enable: 'true' });
+ await setThemeEnabled(theme.entry, true);
loadThemes();
});
card.querySelector('.fv3-theme-disable')?.addEventListener('click', async () => {
- await postForm(API + '/toggle_theme.php', { entry: theme.entry, enable: 'false' });
+ await setThemeEnabled(theme.entry, false);
loadThemes();
});
card.querySelector('.fv3-theme-delete')?.addEventListener('click', () => {
@@ -1132,11 +1132,11 @@
container.querySelectorAll('.fv3-update-check[data-theme]').forEach(el => {
var status = window._fv3ThemeUpdateCache[el.dataset.theme];
if (status === 'update') {
- el.innerHTML = ' update ready ';
+ el.innerHTML = ' ' + escapeAttr(fv3I18nOr('update-ready', 'update ready')) + ' ';
var updateBtn = el.closest('.fv3-theme-card')?.querySelector('.fv3-theme-update');
if (updateBtn) updateBtn.style.display = '';
} else if (status === 'current') {
- el.innerHTML = ' up-to-date ';
+ el.innerHTML = ' ' + escapeAttr(fv3I18nOr('up-to-date', 'up-to-date')) + ' ';
}
});
}
@@ -1288,9 +1288,9 @@
progress.log('This theme references external URLs. Keep it?');
var keep = await progress.confirm('Keep', 'Delete');
if (!keep) {
- await postForm(API + '/delete_theme.php', { entry: result.name + '.disabled' });
- progress.log('Theme deleted.', 'error');
- progress.status('Removed');
+ var del = await postForm(API + '/delete_theme.php', { entry: result.entry });
+ progress.log(del.ok ? 'Theme deleted.' : 'Could not delete the theme.', del.ok ? 'success' : 'error');
+ progress.status(del.ok ? 'Removed' : 'Failed');
} else {
progress.status('Complete');
}
@@ -1384,7 +1384,7 @@
});
},
done: function() {
- statusEl.textContent = 'Finished';
+ if (statusEl.textContent !== 'Failed') statusEl.textContent = 'Finished';
var spinner = titleEl.querySelector('.fv3-swal-spinner');
if (spinner) spinner.style.display = 'none';
if (dotsEl) dotsEl.style.display = 'none';
@@ -1427,8 +1427,8 @@
progress.log('This theme references external URLs. Keep it?');
var keep = await progress.confirm('Keep', 'Delete');
if (!keep) {
- await postForm(API + '/delete_theme.php', { entry: result.name + '.disabled' });
- progress.log('Theme deleted.', 'error');
+ var del = await postForm(API + '/delete_theme.php', { entry: result.entry });
+ progress.log(del.ok ? 'Theme deleted.' : 'Could not delete the theme.', del.ok ? 'success' : 'error');
failed++;
} else {
succeeded++;
@@ -1458,6 +1458,21 @@
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
}
+ // A refused or failed toggle shows why instead of passing silently; returns whether it went through
+ async function setThemeEnabled(entry, enable) {
+ let reason;
+ try {
+ const resp = await postForm(API + '/toggle_theme.php', { entry: entry, enable: enable ? 'true' : 'false' });
+ if (resp.ok) return true;
+ reason = 'HTTP ' + resp.status;
+ try { reason = (await resp.json()).error || reason; } catch (e) {}
+ } catch (e) {
+ reason = e.message || 'Network error';
+ }
+ swal({ title: 'Error', text: 'Could not ' + (enable ? 'enable' : 'disable') + ' the theme: ' + reason, type: 'error' });
+ return false;
+ }
+
async function postFormJson(url, data) {
try {
const resp = await postForm(url, data);
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/custom.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/custom.php
index f8e971f4..121075cf 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/custom.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/custom.php
@@ -1,7 +1,7 @@
{
console.error('[FV3] Dashboard: Docker folder rendering failed:', e);
// Reveal the anti-FOUC-hidden native list — only FV3 grouping failed
document.documentElement.classList.add('fv3-docker-ready');
- if (!e?.fv3Bannered) fv3ShowBanner('FolderView3: Docker folder rendering failed — see the browser console for details.', 'error');
+ if (!e?.fv3Bannered) fv3ShowBanner(fv3I18nOr('docker-render-failed', 'FolderView3: Docker folder rendering failed — see the browser console for details.'), 'error');
} }
@@ -255,7 +255,7 @@ const createFolders = async () => {
} catch (e) {
console.error('[FV3] Dashboard: VM folder rendering failed:', e);
document.documentElement.classList.add('fv3-vm-ready');
- if (!e?.fv3Bannered) fv3ShowBanner('FolderView3: VM folder rendering failed — see the browser console for details.', 'error');
+ if (!e?.fv3Bannered) fv3ShowBanner(fv3I18nOr('vm-render-failed', 'FolderView3: VM folder rendering failed — see the browser console for details.'), 'error');
} }
folderDebugMode = false;
@@ -294,7 +294,7 @@ const createFolderDocker = (folder, id, position, order, containersInfo, folders
// Skip containers explicitly assigned to any folder — explicit beats label (issue #55)
folder.containers = folder.containers.concat(order.filter(el => containersInfo[el]?.Labels?.['folder.view3'] === folder.name && !folder.containers.includes(el) && !(folder.fv3ExplicitMembers || []).includes(el)));
- const fld = `
${escapeHtml(folder.name)} ${$.i18n('stopped')}
`;
+ const fld = `${escapeHtml(folder.name)} ${$.i18n('stopped')}
`;
if (position === 0) {
$('tbody#docker_view > tr.updated > td').children().eq(position).before($(fld));
@@ -510,7 +510,7 @@ const createFolderVM = (folder, id, position, order, vmInfo, foldersDone) => {
} catch (e) { console.error('[FV3] Invalid regex:', folder.regex, e); }
}
- const fld = `${escapeHtml(folder.name)} ${$.i18n('stopped')}
`;
+ const fld = `${escapeHtml(folder.name)} ${$.i18n('stopped')}
`;
if (position === 0) {
$('tbody#vm_view > tr.updated > td').children().eq(position).before($(fld));
@@ -800,46 +800,10 @@ const expandFolderVM = (id) => {
};
// Confirm and delete a docker folder
-const rmDockerFolder = (id) => {
- swal({
- title: $.i18n('are-you-sure'),
- text: `${$.i18n('remove-folder')}: ${escapeHtml(globalFolders.docker[id].name)}`,
- type: 'warning',
- html: true,
- showCancelButton: true,
- confirmButtonText: $.i18n('yes-delete'),
- cancelButtonText: $.i18n('cancel'),
- showLoaderOnConfirm: true
- },
- async (c) => {
- if (!c) { setTimeout(loadlist); return; }
- $('div.spinner.fixed').show('slow');
- await $.post('/plugins/folder.view3/server/delete.php', { type: 'docker', id: id }).promise();
- loadedFolder = false;
- setTimeout(loadlist, 500)
- });
-};
+const rmDockerFolder = (id) => fv3RmFolder('docker', globalFolders.docker, loadlist, id);
// Confirm and delete a vm folder
-const rmVMFolder = (id) => {
- swal({
- title: $.i18n('are-you-sure'),
- text: `${$.i18n('remove-folder')}: ${escapeHtml(globalFolders.vms[id].name)}`,
- type: 'warning',
- html: true,
- showCancelButton: true,
- confirmButtonText: $.i18n('yes-delete'),
- cancelButtonText: $.i18n('cancel'),
- showLoaderOnConfirm: true
- },
- async (c) => {
- if (!c) { setTimeout(loadlist); return; }
- $('div.spinner.fixed').show('slow');
- await $.post('/plugins/folder.view3/server/delete.php', { type: 'vm', id: id }).promise();
- loadedFolder = false;
- setTimeout(loadlist, 500);
- });
-};
+const rmVMFolder = (id) => fv3RmFolder('vm', globalFolders.vms, loadlist, id);
// Navigate to the docker folder edit page
const editDockerFolder = (id) => {
@@ -1460,6 +1424,9 @@ const fv3SettingsReq = $.get('/plugins/folder.view3/server/read_settings.php').p
dashboardContextGraph = parseInt(s.dashboard_context_graph || '1', 10);
dashboardContextGraphTime = parseInt(s.dashboard_context_graph_time || '60', 10);
} catch(e) {}
+}).catch((e) => {
+ console.warn('[FV3] Dashboard settings unavailable, using default layouts:', e);
+ fv3ShowBanner(fv3I18nOr('dashboard-settings-load-failed', 'Could not load Dashboard settings, so the default layouts are shown. Try refreshing the page.'));
});
let dashboardCpus = 1;
@@ -1826,9 +1793,9 @@ window.loadlist = (x) => {
loadedFolder = false;
if($('tbody#docker_view').length > 0) {
folderReq.docker = [
- $.get('/plugins/folder.view3/server/read.php?type=docker').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner('Could not load Docker folder data. Try refreshing the page.', 'error'); }).promise(),
+ $.get('/plugins/folder.view3/server/read.php?type=docker').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner(fv3I18nOr('docker-folders-load-failed', 'Could not load Docker folder data. Try refreshing the page.'), 'error'); }).promise(),
$.get('/plugins/folder.view3/server/read_order.php?type=docker').promise(),
- $.get('/plugins/folder.view3/server/read_info.php?type=docker').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner('Could not load container details. Try refreshing the page.', 'error'); }).promise(),
+ $.get('/plugins/folder.view3/server/read_info.php?type=docker').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner(fv3I18nOr('container-details-load-failed', 'Could not load container details. Try refreshing the page.'), 'error'); }).promise(),
$.get('/plugins/folder.view3/server/read_unraid_order.php?type=docker').promise(),
fv3CheckUpdates()
];
@@ -1836,9 +1803,9 @@ window.loadlist = (x) => {
if($('tbody#vm_view').length > 0) {
folderReq.vm = [
- $.get('/plugins/folder.view3/server/read.php?type=vm').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner('Could not load VM folder data. Try refreshing the page.', 'error'); }).promise(),
+ $.get('/plugins/folder.view3/server/read.php?type=vm').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner(fv3I18nOr('vm-folders-load-failed', 'Could not load VM folder data. Try refreshing the page.'), 'error'); }).promise(),
$.get('/plugins/folder.view3/server/read_order.php?type=vm').promise(),
- $.get('/plugins/folder.view3/server/read_info.php?type=vm').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner('Could not load VM details. Try refreshing the page.', 'error'); }).promise(),
+ $.get('/plugins/folder.view3/server/read_info.php?type=vm').fail((jq) => { jq.fv3Bannered = true; fv3ShowBanner(fv3I18nOr('vm-details-load-failed', 'Could not load VM details. Try refreshing the page.'), 'error'); }).promise(),
$.get('/plugins/folder.view3/server/read_unraid_order.php?type=vm').promise()
];
}
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/docker.js b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/docker.js
index 1256ee88..ef7c29b0 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/docker.js
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/docker.js
@@ -292,7 +292,7 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container
fv3Debug('createFolder', id, 'combinedContainers', [...combinedContainers]);
const colspan = document.querySelector("#docker_containers > thead > tr").childElementCount - 5;
- const fld = ` ${$.i18n('up-to-date')}
0%
0 / 0
`;
+ const fld = ` ${$.i18n('up-to-date')}
0%
0 / 0
`;
fv3Debug('createFolder', id, `colspan=${colspan}. Generated folder HTML (fld).`);
if (positionInMainOrder === 0) {
@@ -690,7 +690,7 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container
} else if (!upToDate) {
$(`tr.folder-id-${id} > td.updatecolumn > span`).replaceWith($(` ${$.i18n('update-ready')}
`));
$(`tr.folder-id-${id} > td.updatecolumn > div.advanced:has(a)`).remove();
- $(`tr.folder-id-${id} > td.updatecolumn`).append($(` ${$.i18n('apply-update')} `));
+ $(`tr.folder-id-${id} > td.updatecolumn`).append($(` ${$.i18n('apply-update')} `));
fv3Debug('createFolder', id, 'Set update ready status in update column.');
if (folder.settings && folder.settings.preview_update_folder) {
$(`tr.folder-id-${id} a.folder-appname`).addClass('orange-text');
@@ -751,7 +751,8 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container
const folderAutostart = async (el) => {
fv3Debug('folderAutostart', 'Entry. Event target:', el.target);
const status = el.target.checked;
- const id = el.target.id.split('-')[1];
+ // Element id is folder--auto and ids may contain '-', so strip the ends rather than split
+ const id = el.target.id.slice('folder-'.length, -'-auto'.length);
fv3Debug('folderAutostart', `Folder ID: ${id}, New Status: ${status}`);
const containers = $(`.folder-${id}-element`);
fv3Debug('folderAutostart', `Found ${containers.length} containers in folder ${id}.`);
@@ -1165,10 +1166,10 @@ window.loadlist = () => {
fv3OrganizerSyncDone = false;
fv3Debug('Patched loadlist', 'Set loadedFolder to false.');
folderReq = [
- $.get('/plugins/folder.view3/server/read.php?type=docker').fail(() => fv3ShowBanner('Could not load folder data. Try refreshing the page.', 'error')).promise(),
- $.get('/plugins/folder.view3/server/read_order.php?type=docker').promise(),
- $.get('/plugins/folder.view3/server/read_info.php?type=docker').fail(() => fv3ShowBanner('Could not load container details. Try refreshing the page.', 'error')).promise(),
- $.get('/plugins/folder.view3/server/read_unraid_order.php?type=docker').promise(),
+ $.get('/plugins/folder.view3/server/read.php?type=docker').fail(() => fv3ShowBanner(fv3I18nOr('folder-data-load-failed', 'Could not load folder data. Try refreshing the page.'), 'error')).promise(),
+ $.get('/plugins/folder.view3/server/read_order.php?type=docker').fail(() => fv3ShowBanner(fv3I18nOr('folder-order-load-failed', 'Could not load folder order. Try refreshing the page.'), 'error')).promise(),
+ $.get('/plugins/folder.view3/server/read_info.php?type=docker').fail(() => fv3ShowBanner(fv3I18nOr('container-details-load-failed', 'Could not load container details. Try refreshing the page.'), 'error')).promise(),
+ $.get('/plugins/folder.view3/server/read_unraid_order.php?type=docker').fail(() => fv3ShowBanner(fv3I18nOr('folder-order-load-failed', 'Could not load folder order. Try refreshing the page.'), 'error')).promise(),
fv3CheckUpdates()
];
Promise.all(folderReq).finally(() => { fv3FolderReqPending = false; });
@@ -1382,11 +1383,11 @@ if (typeof resetSorting === 'function') {
window.resetSorting = function() {
if ($.cookie('lockbutton') == null) return;
$('input[type=button]').prop('disabled', true);
- $.post('/plugins/dynamix.docker.manager/include/UserPrefs.php', {reset: true}, function() {
- $.post('/plugins/folder.view3/server/sync_order.php', {type: 'docker'}, function() {
- loadlist();
- });
- });
+ // always(): a failed reset or sync must still reload, or the disabled buttons stay stuck
+ $.post('/plugins/dynamix.docker.manager/include/UserPrefs.php', {reset: true})
+ .then(() => $.post('/plugins/folder.view3/server/sync_order.php', {type: 'docker'}))
+ .fail(() => fv3ShowBanner(fv3I18nOr('reset-order-failed', 'Could not reset the folder order. Try again.')))
+ .always(() => loadlist());
};
fv3Debug('init', 'resetSorting patched for folder-grouped autostart');
}
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folder.js b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folder.js
index 19c87fcc..5614ba20 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folder.js
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folder.js
@@ -35,6 +35,11 @@ let labelFolderNames = new Set();
let otherExplicitMembers = new Set();
const type = new URLSearchParams(location.search).get('type');
const folderId = new URLSearchParams(location.search).get('id');
+// Only a missing id means a new folder: ?id= is a real (hand-edited) '' key, which must load, not be created again
+const isEdit = folderId !== null;
+// Save stays blocked until setup finishes: a rejected load (either mode) must not overwrite a folder with a half-built form
+let fv3FolderLoaded = false;
+const fv3LoadFailedAlert = () => swal({ title: 'Error', text: fv3I18nOr('folder-load-failed', 'This folder could not be loaded, so saving is disabled. Reload the page and try again.'), type: 'error' });
const rgbToHex = (rgb) => {
rgb = rgb.slice(4, -1).split(', ');
@@ -97,8 +102,12 @@ $('div.canvas > form')[0].preview_vertical_bars_color.value = rgbToHex($('body')
choose = Object.values(fv3SafeParse(await $.get(`/plugins/folder.view3/server/read_info.php?type=${type}`).promise(), {})).map(typeFilter);
- if (folderId) {
+ if (isEdit) {
const currFolder = folders[folderId];
+ if (!currFolder) {
+ swal({ title: 'Error', text: fv3I18nOr('folder-not-found', 'This folder no longer exists.'), type: 'error' });
+ return;
+ }
delete folders[folderId];
if (!currFolder.settings) currFolder.settings = {};
@@ -265,7 +274,12 @@ $('div.canvas > form')[0].preview_vertical_bars_color.value = rgbToHex($('body')
}
$('.canvas form div.basic > dl > dt').css('cursor', 'default').wrapInner(' ');
-})();
+ // Reached only if every await above resolved; a rejection skips this and the catch alerts
+ fv3FolderLoaded = true;
+})().catch((err) => {
+ console.error('[FV3] Folder editor setup failed:', err);
+ if (!fv3FolderLoaded) fv3LoadFailedAlert();
+});
// Update the folder icon preview from the icon field value.
const updateIcon = (e) => {
@@ -442,6 +456,7 @@ const syncHidePreview = ($row) => {
// Serialize the form to a folder object, POST create/update, then return to the tab. Returns false.
const submitForm = async (e) => {
+ if (!fv3FolderLoaded) { fv3LoadFailedAlert(); return false; }
// 'root' is reserved by Unraid's Docker organizer — server rejects it too
if (e.name.value.toString().trim().toLowerCase() === 'root') {
swal({ title: 'Reserved Name', text: "'root' is reserved by Unraid's Docker organizer — please pick another folder name.", type: 'error' });
@@ -506,19 +521,29 @@ const submitForm = async (e) => {
hidden_preview: [...$('input.preview-switch:checked').map((i, e) => $(e).val())],
actions
}
- if (folderId) {
- await $.post('/plugins/folder.view3/server/update.php', { type: type, content: JSON.stringify(folder), id: folderId });
- } else {
- await $.post('/plugins/folder.view3/server/create.php', { type: type, content: JSON.stringify(folder) });
+ try {
+ if (isEdit) {
+ await $.post('/plugins/folder.view3/server/update.php', { type: type, content: JSON.stringify(folder), id: folderId });
+ } else {
+ await $.post('/plugins/folder.view3/server/create.php', { type: type, content: JSON.stringify(folder) });
+ }
+ } catch (err) {
+ swal({ title: 'Error', text: fv3I18nOr('save-folder-failed', 'Could not save the folder: $1', fv3FailReason(err)), type: 'error' });
+ return false;
}
+ const back = () => { const loc = location.pathname.split('/'); loc.pop(); location.href = loc.join('/'); };
if (type === 'docker') {
- await $.post('/plugins/folder.view3/server/sync_order.php', { type: type });
+ // The folder is already saved: report a failed order sync, then still return to the tab
+ try {
+ await $.post('/plugins/folder.view3/server/sync_order.php', { type: type }).promise();
+ } catch (err) {
+ console.warn('[FV3] Autostart order sync failed after save:', fv3FailReason(err));
+ swal({ title: 'Warning', text: fv3I18nOr('order-sync-failed', 'Saved, but the Docker start order could not be updated: $1', fv3FailReason(err)), type: 'warning' }, back);
+ return false;
+ }
}
-
- let loc = location.pathname.split('/');
- loc.pop();
- location.href = loc.join('/');
+ back();
return false;
}
@@ -534,7 +559,7 @@ const cancelBtn = () => {
* Handles the Delete folder button — confirmation dialog + POST to delete.php
*/
const deleteFolderBtn = () => {
- if (!folderId) return;
+ if (!isEdit) return;
const folderName = $('div.canvas > form')[0]?.name?.value || folderId;
swal({
title: $.i18n('delete-folder-confirm-title') || 'Delete folder?',
@@ -544,7 +569,9 @@ const deleteFolderBtn = () => {
confirmButtonText: $.i18n('delete') || 'Delete',
cancelButtonText: $.i18n('cancel') || 'Cancel',
confirmButtonColor: '#a02020',
- closeOnConfirm: true
+ // Stay open until the delete settles: a swal reopened inside close()'s hide timer is blanked
+ showLoaderOnConfirm: true,
+ closeOnConfirm: false
}, async (confirmed) => {
if (!confirmed) return;
try {
@@ -553,8 +580,7 @@ const deleteFolderBtn = () => {
loc.pop();
location.href = loc.join('/');
} catch (err) {
- const msg = err.responseText || err.statusText || err.message || 'Unknown error';
- swal({ title: 'Error', text: 'Failed to delete folder: ' + msg, type: 'error' });
+ swal({ title: 'Error', text: fv3I18nOr('delete-folder-failed', 'Could not delete folder "$1": $2', folderName, fv3FailReason(err)), type: 'error' });
}
});
};
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folderview3.js b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folderview3.js
index fec096f1..f2352f42 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folderview3.js
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/folderview3.js
@@ -78,7 +78,7 @@ const populateTable = async () => {
]);
} catch (e) {
console.error('[FV3] Failed to load folder data:', e);
- fv3ShowBanner?.('error', 'Failed to load folder data. Please refresh the page.');
+ swal({ title: 'Error', text: fv3I18nOr('folder-data-load-failed', 'Could not load folder data. Try refreshing the page.'), type: 'error' });
return;
}
const dockerData = fv3SafeParse(proms[0], {});
@@ -95,17 +95,18 @@ const populateTable = async () => {
dockerTable.empty();
vmsTable.empty();
+ // Ids reach the inline handlers as escaped JSON literals, so any stored key (quotes included) is passed intact, never run
const dockerIds = orderFolderIds(dockers, currentDockerContainerOrder);
for (const id of dockerIds) {
const folder = dockers[id];
- const fld = `${escapeHtml(id)} ${escapeHtml(folder.name)} `;
+ const fld = `${escapeHtml(id)} ${escapeHtml(folder.name)} `;
dockerTable.append($(fld));
}
const vmIds = orderFolderIds(vms, currentVmOrder);
for (const id of vmIds) {
const folder = vms[id];
- const fld = `${escapeHtml(id)} ${escapeHtml(folder.name)} `;
+ const fld = `${escapeHtml(id)} ${escapeHtml(folder.name)} `;
vmsTable.append($(fld));
}
@@ -130,7 +131,7 @@ const buildOrderedExport = async (folders, type) => {
};
const downloadDocker = async (id) => {
- if (id) {
+ if (id !== undefined) {
downloadFile(`${dockers[id].name}.json`, JSON.stringify(dockers[id]));
} else {
try {
@@ -154,18 +155,31 @@ const fv3CountFolderExport = (parsed) => {
return (folders.length && folders.every(fv3IsFolderShaped)) ? folders.length : 0;
};
+// Imports every folder, carrying on past failures; closes any open modal or reports the ones refused
const fv3ImportFolderMap = async (content, type) => {
+ if (!content || typeof content !== 'object' || Array.isArray(content)) {
+ swal({ title: 'Error', text: fv3I18nOr('invalid-folder-export', 'This file is not a folder export.'), type: 'error' });
+ return;
+ }
// Structural test, not `content.name` — an empty name is legal and would otherwise route a
// single folder down the map path, writing its own keys (name, icon, settings…) as folder ids.
- if (fv3IsFolderShaped(content)) {
- await $.post('/plugins/folder.view3/server/create.php', { type: type, content: JSON.stringify(content) });
- } else {
- for (const [id, folder] of Object.entries(content)) {
- await $.post('/plugins/folder.view3/server/update.php', { type: type, content: JSON.stringify(folder), id: id });
+ const jobs = fv3IsFolderShaped(content)
+ ? [{ url: '/plugins/folder.view3/server/create.php', data: { type: type, content: JSON.stringify(content) }, label: content.name || 'folder' }]
+ : Object.entries(content).map(([id, folder]) => ({ url: '/plugins/folder.view3/server/update.php', data: { type: type, content: JSON.stringify(folder), id: id }, label: folder?.name || id }));
+ const failed = await postEach(jobs);
+ let syncError = null;
+ if (type === 'docker') {
+ try {
+ await $.post('/plugins/folder.view3/server/sync_order.php', { type: 'docker' }).promise();
+ } catch (error) {
+ console.warn('[FV3] Autostart order sync failed after import:', fv3FailReason(error));
+ syncError = fv3FailReason(error);
}
- if (type === 'docker') await $.post('/plugins/folder.view3/server/sync_order.php', { type: 'docker' });
}
populateTable();
+ const syncText = syncError ? fv3I18nOr('order-sync-failed', 'Saved, but the Docker start order could not be updated: $1', syncError) : '';
+ if (showBatchErrors(failed, { key: 'import-folders-failed', text: 'These folders could not be imported: $1' }, null, syncText)) return;
+ if (syncText) { swal({ title: 'Warning', text: syncText, type: 'warning' }); } else { swal.close(); }
};
const importDocker = () => {
@@ -236,58 +250,64 @@ const importVm = () => {
input.click();
};
-const clearDocker = (id) => {
- if (id) {
- swal({
- title: 'Are you sure?',
- text: `Remove folder: ${escapeHtml(dockers[id].name)}`,
- type: 'warning',
- html: true,
- showCancelButton: true,
- confirmButtonText: 'Yes, delete it!',
- cancelButtonText: 'Cancel',
- showLoaderOnConfirm: true
- },
- async (c) => {
- if (!c) { return; }
- try {
- await $.post('/plugins/folder.view3/server/delete.php', { type: 'docker', id: id }).promise();
- } catch (error) {
- console.error('Docker delete error:', error);
- swal({ title: 'Error', text: 'Failed to delete folder: ' + error.statusText, type: 'error' });
- return;
- }
- populateTable();
- });
- } else {
- swal({
- title: 'Are you sure?',
- text: 'Remove ALL folders',
- type: 'warning',
- html: true,
- showCancelButton: true,
- confirmButtonText: 'Yes, delete it!',
- cancelButtonText: 'Cancel',
- showLoaderOnConfirm: true
- },
- async (c) => {
- if (!c) { return; }
- try {
- for (const cid of Object.keys(dockers)) {
- await $.post('/plugins/folder.view3/server/delete.php', { type: 'docker', id: cid }).promise();
- }
- } catch (error) {
- console.error('Docker clear all error:', error);
- swal({ title: 'Error', text: 'Failed to clear all folders', type: 'error' });
- return;
- }
- populateTable();
- });
+// Confirm modals stay open until the request settles: a swal reopened inside close()'s hide timer is blanked
+const swalLoaderOpts = { showLoaderOnConfirm: true, closeOnConfirm: false };
+
+// Posts each job in turn, carrying on past failures; returns { label, reason } for each one that failed
+const postEach = async (jobs) => {
+ const failed = [];
+ for (const { url, data, label } of jobs) {
+ try {
+ await $.post(url, data).promise();
+ } catch (error) {
+ console.error('[FV3] Request failed:', label, error);
+ failed.push({ label, reason: fv3FailReason(error) });
+ }
}
+ return failed;
};
+// Replaces the open dialog with one error for a batch: a lone failure with its reason when `one` is given, else the
+// failed names. Returns false, showing nothing, when every job went through
+const showBatchErrors = (failed, many, one = null, extra = '') => {
+ if (!failed.length) return false;
+ const text = failed.length === 1 && one
+ ? fv3I18nOr(one.key, one.text, failed[0].label, failed[0].reason)
+ : fv3I18nOr(many.key, many.text, failed.map(f => f.label).join(', '));
+ swal({ title: 'Error', text: extra ? text + '\n' + extra : text, type: 'error' });
+ return true;
+};
+
+// Deletes one folder, or every folder of the type when id is omitted; the confirm stays open until the deletes settle
+const clearFolders = (type, id) => {
+ const folders = type === 'docker' ? dockers : vms;
+ // Only an omitted id means every folder: '' is a real (hand-edited) key
+ const ids = id !== undefined ? [id] : Object.keys(folders);
+ swal({
+ title: 'Are you sure?',
+ text: id !== undefined ? `Remove folder: ${escapeHtml(folders[id].name)}` : 'Remove ALL folders',
+ type: 'warning',
+ html: true,
+ showCancelButton: true,
+ confirmButtonText: 'Yes, delete it!',
+ cancelButtonText: 'Cancel',
+ ...swalLoaderOpts
+ },
+ async (c) => {
+ if (!c) { return; }
+ const failed = await postEach(ids.map(fid => ({ url: '/plugins/folder.view3/server/delete.php', data: { type: type, id: fid }, label: folders[fid].name || fid })));
+ populateTable();
+ if (!showBatchErrors(failed,
+ { key: 'clear-folders-failed', text: 'These folders could not be deleted: $1' },
+ { key: 'delete-folder-failed', text: 'Could not delete folder "$1": $2' })) { swal.close(); }
+ });
+};
+
+const clearDocker = (id) => clearFolders('docker', id);
+const clearVm = (id) => clearFolders('vm', id);
+
const downloadVm = async (id) => {
- if (id) {
+ if (id !== undefined) {
downloadFile(`${vms[id].name}.json`, JSON.stringify(vms[id]));
} else {
try {
@@ -298,56 +318,6 @@ const downloadVm = async (id) => {
}
};
-const clearVm = (id) => {
- if (id) {
- swal({
- title: 'Are you sure?',
- text: `Remove folder: ${escapeHtml(vms[id].name)}`,
- type: 'warning',
- html: true,
- showCancelButton: true,
- confirmButtonText: 'Yes, delete it!',
- cancelButtonText: 'Cancel',
- showLoaderOnConfirm: true
- },
- async (c) => {
- if (!c) { return; }
- try {
- await $.post('/plugins/folder.view3/server/delete.php', { type: 'vm', id: id }).promise();
- } catch (error) {
- console.error('VM delete error:', error);
- swal({ title: 'Error', text: 'Failed to delete folder', type: 'error' });
- return;
- }
- populateTable();
- });
- } else {
- swal({
- title: 'Are you sure?',
- text: 'Remove ALL folders',
- type: 'warning',
- html: true,
- showCancelButton: true,
- confirmButtonText: 'Yes, delete it!',
- cancelButtonText: 'Cancel',
- showLoaderOnConfirm: true
- },
- async (c) => {
- if (!c) { return; }
- try {
- for (const cid of Object.keys(vms)) {
- await $.post('/plugins/folder.view3/server/delete.php', { type: 'vm', id: cid }).promise();
- }
- } catch (error) {
- console.error('VM clear all error:', error);
- swal({ title: 'Error', text: 'Failed to clear all folders', type: 'error' });
- return;
- }
- populateTable();
- });
- }
-};
-
const downloadFile = (name, content) => {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -483,7 +453,7 @@ const loadDashboardSettings = async () => {
}
};
-const fv3SubmitSettings = async () => {
+const fv3SubmitSettings = async (quiet = false) => {
const current = fv3CollectSettings();
const changed = {};
for (const [key, value] of Object.entries(current)) {
@@ -498,8 +468,8 @@ const fv3SubmitSettings = async () => {
changed.dashboard_vm_expand_toggle = 'no';
}
if (Object.keys(changed).length === 0) {
- swal({ title: 'No Changes', text: 'Settings are unchanged.', type: 'info', timer: 1500 });
- return;
+ if (!quiet) swal({ title: 'No Changes', text: 'Settings are unchanged.', type: 'info', timer: 1500 });
+ return true;
}
try {
await $.ajax({
@@ -509,11 +479,13 @@ const fv3SubmitSettings = async () => {
}).promise();
fv3LoadedSettings = { ...fv3LoadedSettings, ...changed };
fv3ApplyFormState(fv3LoadedSettings);
- swal({ title: 'Saved', text: 'Settings saved.', type: 'success', timer: 1500 });
+ if (!quiet) swal({ title: 'Saved', text: 'Settings saved.', type: 'success', timer: 1500 });
+ return true;
} catch (e) {
var msg = e.responseText || e.statusText || e.message || 'Unknown error';
console.error('Failed to save settings:', msg);
swal({ title: 'Error', text: 'Failed to save settings: ' + msg, type: 'error' });
+ return false;
}
};
@@ -539,10 +511,12 @@ $('#fv3-apply-defaults').on('click', function() {
text: 'This will update all existing folders to use the current default settings. Per-folder overrides will be replaced.',
type: 'warning',
showCancelButton: true,
- confirmButtonText: 'Apply'
+ confirmButtonText: 'Apply',
+ ...swalLoaderOpts
}, async (confirmed) => {
if (!confirmed) return;
- await fv3SubmitSettings();
+ // Quiet: its timed toasts would close this dialog's result. A failed save shows its error and stops here
+ if (!(await fv3SubmitSettings(true))) return;
const settings = fv3CollectSettings();
const defaultMap = {
preview: parseInt(settings.default_preview !== undefined ? settings.default_preview : '1', 10),
@@ -569,6 +543,7 @@ $('#fv3-apply-defaults').on('click', function() {
update_column: settings.default_update_column === 'yes',
use_global_defaults: true
};
+ const jobs = [];
for (const [type, folders] of [['docker', dockers], ['vm', vms]]) {
for (const [id, folder] of Object.entries(folders)) {
if (!folder.settings) folder.settings = {};
@@ -579,11 +554,11 @@ $('#fv3-apply-defaults').on('click', function() {
delete applyMap.preview_row_separator_color;
}
Object.assign(folder.settings, applyMap);
- await $.post('/plugins/folder.view3/server/update.php', {
- type, id, content: JSON.stringify(folder)
- }).promise();
+ jobs.push({ url: '/plugins/folder.view3/server/update.php', data: { type, id, content: JSON.stringify(folder) }, label: folder.name || id });
}
}
+ const failed = await postEach(jobs);
+ if (showBatchErrors(failed, { key: 'defaults-not-applied', text: 'Defaults were not applied to these folders: $1' })) return;
swal({ title: 'Done', text: 'Defaults applied to all folders.', type: 'success', timer: 1500 });
});
});
@@ -613,11 +588,12 @@ const fv3ExportAll = async () => {
window.fv3ExportAll = fv3ExportAll;
const fv3ImportFolderExport = async (content, type) => {
- swal.close();
+ // Replace the choice modal rather than close it: a swal reopened inside close()'s hide timer is blanked
+ swal({ title: fv3I18nOr('importing-folders', 'Importing folders…'), text: '', showConfirmButton: false });
try {
await fv3ImportFolderMap(content, type);
} catch (err) {
- swal({ title: 'Error', text: 'Import failed.', type: 'error' });
+ swal({ title: 'Error', text: fv3I18nOr('import-failed', 'Import failed: $1', fv3FailReason(err)), type: 'error' });
}
};
@@ -667,11 +643,21 @@ $('#fv3-import-all').on('change', function() {
text: 'This will overwrite current config with: ' + items.join(', ') + '.' + (parsed.exported ? '\nExported: ' + parsed.exported : ''),
type: 'warning',
showCancelButton: true,
- confirmButtonText: 'Import'
+ confirmButtonText: 'Import',
+ ...swalLoaderOpts
}, async (confirmed) => {
if (!confirmed) return;
- const resp = await $.post('/plugins/folder.view3/server/import_all.php', { bundle: JSON.stringify(parsed) }).promise();
- const result = (typeof resp === 'object') ? resp : fv3SafeParse(resp, {});
+ let result;
+ try {
+ const resp = await $.post('/plugins/folder.view3/server/import_all.php', { bundle: JSON.stringify(parsed) }).promise();
+ result = (typeof resp === 'object' && resp !== null) ? resp : fv3SafeParse(resp, null);
+ // A reply that isn't a result object is a failed restore, never "0 items restored"
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Invalid restore response');
+ } catch (err) {
+ console.error('Import Everything error:', err);
+ swal({ title: 'Error', text: fv3I18nOr('import-failed', 'Import failed: $1', fv3FailReason(err)), type: 'error' });
+ return;
+ }
if (result.error) {
swal({ title: 'Error', text: result.error, type: 'error' });
} else {
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/shared.js b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/shared.js
index 6a24c342..c96d0fe0 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/shared.js
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/shared.js
@@ -688,7 +688,7 @@ window.fv3ShowBanner = (message, level) => {
};
window.fv3EditFolder = (type, basePath, id) => {
- location.href = basePath + '?type=' + type + '&id=' + id;
+ location.href = basePath + '?type=' + encodeURIComponent(type) + '&id=' + encodeURIComponent(id);
};
window.fv3CreateFolderBtn = (type, basePath) => {
@@ -709,7 +709,13 @@ window.fv3RmFolder = (type, globalFolders, loadlist, id) => {
async (c) => {
if (!c) { setTimeout(loadlist, 0); return; }
$('div.spinner.fixed').show('slow');
- await $.post('/plugins/folder.view3/server/delete.php', { type: type, id: id }).promise();
+ try {
+ await $.post('/plugins/folder.view3/server/delete.php', { type: type, id: id }).promise();
+ } catch (e) {
+ $('div.spinner.fixed').hide('slow');
+ fv3ShowBanner(fv3I18nOr('delete-folder-failed', 'Could not delete folder "$1": $2', globalFolders[id].name || id, fv3FailReason(e)));
+ return;
+ }
setTimeout(loadlist, 500);
});
};
@@ -1329,12 +1335,12 @@ window.fv3RunUserScript = async (act, prom) => {
const cmd = await $.post("/plugins/user.scripts/exec.php",{action:'convertScript', path:`/boot/config/plugins/user.scripts/scripts/${act.script}/script`}).promise();
prom.push($.get('/logging.htm?cmd=/plugins/user.scripts/backgroundScript.sh&arg1='+cmd+'&arg2='+args+'&csrf_token='+csrf_token+'&done=Done').promise());
}
- } catch (e) { fv3ShowBanner('Could not run the user script — check the User Scripts plugin is installed and the script exists.'); }
+ } catch (e) { fv3ShowBanner(fv3I18nOr('user-script-failed', 'Could not run the user script — check the User Scripts plugin is installed and the script exists.')); }
};
// Row separators
window.fv3UpdateRowSeparators = (folderMap, folderId) => {
- const ids = folderId ? [folderId] : Object.keys(folderMap);
+ const ids = folderId !== undefined ? [folderId] : Object.keys(folderMap);
ids.forEach(id => {
const folder = folderMap[id];
if (!folder || !folder.settings.preview_row_separator || folder.settings.preview_overflow !== 1) return;
@@ -1507,7 +1513,7 @@ window.fv3ContainerAction = async (type, id, action) => {
window.fv3DockerAction = (action, containerId, fullId) => {
var gqlAction = fv3DockerActionMap[action];
var phpFallback = () => $.post(window.eventURL || '/update.htm', { action: action, container: containerId }, null, 'json').promise()
- .fail(() => fv3ShowBanner('Could not ' + action + ' container. Check your server connection.'));
+ .fail(() => fv3ShowBanner(fv3I18nOr('container-action-failed', 'The container action "$1" failed. Check your server connection.', action)));
if (fv3ApiAvailable && gqlAction && fullId) {
return fv3GraphQL('mutation($id: PrefixedID!) { docker { ' + gqlAction + '(id: $id) { id } } }', { id: fullId })
.then(() => { fv3Debug('API', 'Docker', gqlAction, containerId, 'OK'); return { success: true }; })
@@ -1522,7 +1528,7 @@ window.fv3DockerAction = (action, containerId, fullId) => {
window.fv3VmAction = (action, uuid) => {
var gqlAction = fv3VmActionMap[action];
var phpFallback = () => $.post('/plugins/dynamix.vm.manager/include/VMajax.php', { action: action, uuid: uuid }, null, 'json').promise()
- .fail(() => fv3ShowBanner('Could not ' + action.replace('domain-', '') + ' VM. Check your server connection.'));
+ .fail(() => fv3ShowBanner(fv3I18nOr('vm-action-failed', 'The VM action "$1" failed. Check your server connection.', action.replace('domain-', ''))));
if (fv3ApiAvailable && gqlAction) {
return fv3GraphQL('mutation($id: PrefixedID!) { vm { ' + gqlAction + '(id: $id) } }', { id: uuid })
.then(() => { fv3Debug('API', 'VM', gqlAction, uuid, 'OK'); return { success: true }; })
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/vm.js b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/vm.js
index c0f9841d..0f0fd480 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/vm.js
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/scripts/vm.js
@@ -194,7 +194,7 @@ const createFolder = (folder, id, position, order, vmInfo, foldersDone) => {
const totalCols = document.querySelector("#kvm_table > thead > tr").childElementCount;
const colspan = totalCols - 2;
- const fld = `
`;
+ const fld = `
`;
if (position === 0) {
$('#kvm_list > tr.sortable').eq(position).before($(fld));
@@ -456,7 +456,8 @@ const createFolder = (folder, id, position, order, vmInfo, foldersDone) => {
// Autostart toggle handler: syncs each VM's autostart to the folder's new state.
const folderAutostart = (el) => {
const status = el.target.checked;
- const id = el.target.id.split('-')[1];
+ // Element id is folder--auto and ids may contain '-', so strip the ends rather than split
+ const id = el.target.id.slice('folder-'.length, -'-auto'.length);
const containers = $(`tr.folder-${id}-element`);
for (const container of containers) {
const el = $(container).children().last();
@@ -764,10 +765,10 @@ window.loadlist = (x) => {
fv3FolderReqPending = true;
loadedFolder = false;
folderReq = [
- $.get('/plugins/folder.view3/server/read.php?type=vm').fail(() => fv3ShowBanner('Could not load folder data. Try refreshing the page.', 'error')).promise(),
- $.get('/plugins/folder.view3/server/read_order.php?type=vm').promise(),
- $.get('/plugins/folder.view3/server/read_info.php?type=vm').fail(() => fv3ShowBanner('Could not load VM details. Try refreshing the page.', 'error')).promise(),
- $.get('/plugins/folder.view3/server/read_unraid_order.php?type=vm').promise()
+ $.get('/plugins/folder.view3/server/read.php?type=vm').fail(() => fv3ShowBanner(fv3I18nOr('folder-data-load-failed', 'Could not load folder data. Try refreshing the page.'), 'error')).promise(),
+ $.get('/plugins/folder.view3/server/read_order.php?type=vm').fail(() => fv3ShowBanner(fv3I18nOr('folder-order-load-failed', 'Could not load folder order. Try refreshing the page.'), 'error')).promise(),
+ $.get('/plugins/folder.view3/server/read_info.php?type=vm').fail(() => fv3ShowBanner(fv3I18nOr('vm-details-load-failed', 'Could not load VM details. Try refreshing the page.'), 'error')).promise(),
+ $.get('/plugins/folder.view3/server/read_unraid_order.php?type=vm').fail(() => fv3ShowBanner(fv3I18nOr('folder-order-load-failed', 'Could not load folder order. Try refreshing the page.'), 'error')).promise()
];
Promise.all(folderReq).finally(() => { fv3FolderReqPending = false; });
}
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/create.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/create.php
index e1e09ee1..720250d7 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/create.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/create.php
@@ -2,5 +2,5 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
$type = fv3_validate_type($_POST['type'] ?? '');
- updateFolder($type, $_POST['content']);
+ updateFolder($type, fv3_post_string('content'));
?>
\ No newline at end of file
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete.php
index b2d919da..158b3581 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete.php
@@ -2,10 +2,6 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
$type = fv3_validate_type($_POST['type'] ?? '');
- $id = $_POST['id'] ?? '';
- if (empty($id) || !preg_match('/^[A-Za-z0-9+\/=]+$/', $id)) {
- http_response_code(400);
- exit;
- }
- deleteFolder($type, $id);
+ // A missing or non-string id is refused; any existing key stays deletable, '' and ids fv3_is_folder_id() refuses included
+ deleteFolder($type, fv3_post_required('id'));
?>
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete_theme.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete_theme.php
index c936eec5..7e805ceb 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete_theme.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/delete_theme.php
@@ -1,6 +1,5 @@
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_all.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_all.php
index ccc243c0..c087e9c3 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_all.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_all.php
@@ -2,7 +2,7 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
header('Content-Type: application/json');
- $json = $_POST['bundle'] ?? '';
+ $json = fv3_post_string('bundle');
if ($json === '') { http_response_code(400); echo json_encode(['error' => 'Missing bundle']); exit; }
echo json_encode(importAll($json));
?>
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_theme.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_theme.php
index 5afee76d..00331284 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_theme.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/import_theme.php
@@ -2,8 +2,10 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
header('Content-Type: application/json');
- $repo = $_POST['repo'] ?? '';
- $path = $_POST['path'] ?? '';
- $branch = $_POST['branch'] ?? '';
- echo json_encode(importTheme($repo, $path, $branch));
+ $repo = fv3_post_string('repo');
+ $path = fv3_post_string('path');
+ $branch = fv3_post_string('branch');
+ $result = importTheme($repo, $path, $branch);
+ if (isset($result['error'])) http_response_code(400);
+ echo json_encode($result);
?>
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 049bb598..de37f196 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
@@ -87,6 +87,27 @@ function fv3_validate_type($type): string {
return $type;
}
+ // A POST field as a string, else a 400: like type above, an x[]= array must not reach a typed param
+ function fv3_post_string(string $key, string $default = ''): string {
+ $value = $_POST[$key] ?? $default;
+ if (!is_string($value)) {
+ http_response_code(400);
+ exit;
+ }
+ return $value;
+ }
+
+ // For a field whose absence would read as "empty" and wipe saved state: missing is a 400, never a default
+ function fv3_post_required(string $key): string {
+ if (!array_key_exists($key, $_POST)) {
+ http_response_code(400);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => "Missing field: $key"]);
+ exit;
+ }
+ return fv3_post_string($key);
+ }
+
function fv3_security_headers(): void {
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
@@ -196,7 +217,42 @@ function readFolder(string $type) : string {
global $configDir;
if(!file_exists("$configDir/$type.json")) { createFile($type); }
$raw = @file_get_contents("$configDir/$type.json");
- return ($raw !== false) ? $raw : '{}';
+ if ($raw === false) {
+ // Fail the request: a 200 '{}' would be cached client-side over the last good copy
+ http_response_code(500);
+ return json_encode(['error' => "$type.json is unreadable"]);
+ }
+ // Unparseable stays byte-identical so the client's cached-copy fallback still fires
+ $decoded = json_decode($raw, true);
+ if (json_last_error() !== JSON_ERROR_NONE) { return $raw; }
+ // Valid JSON that isn't a folder map fails like an unreadable file
+ if (!is_array($decoded)) {
+ http_response_code(500);
+ return json_encode(['error' => "$type.json does not contain a folder map"]);
+ }
+ $clean = json_encode((object)fv3_normalize_folders($decoded));
+ return $clean !== false ? $clean : $raw;
+ }
+
+ // Fills the fields every reader dereferences so a hand-edited folder can't break a render
+ // or the autostart sync; object fields stay JSON objects. Never writes the file.
+ function fv3_normalize_folders(array $folders): array {
+ $out = [];
+ foreach ($folders as $id => $f) {
+ if (!is_array($f)) { continue; }
+ $f['name'] = is_scalar($f['name'] ?? null) ? (string)$f['name'] : (string)$id;
+ $f['icon'] = is_string($f['icon'] ?? null) ? $f['icon'] : '';
+ $f['regex'] = is_string($f['regex'] ?? null) ? $f['regex'] : '';
+ $f['containers'] = is_array($f['containers'] ?? null) ? array_values($f['containers']) : [];
+ $f['hidden_preview'] = is_array($f['hidden_preview'] ?? null) ? array_values($f['hidden_preview']) : [];
+ $f['actions'] = is_array($f['actions'] ?? null) ? array_values(array_filter($f['actions'], 'is_array')) : [];
+ $f['settings'] = (object)(is_array($f['settings'] ?? null) ? $f['settings'] : []);
+ foreach (['containerImages', 'containerIds'] as $k) {
+ if (array_key_exists($k, $f)) { $f[$k] = (object)(is_array($f[$k]) ? $f[$k] : []); }
+ }
+ $out[$id] = $f;
+ }
+ return $out;
}
function readUserPrefs(string $type) : string {
@@ -498,13 +554,10 @@ function fv3_read_container_labels(DockerClient $dockerClient, array $allContain
}
// Effective membership — explicit > label > regex (issues #46/#55). Single source of
- // truth shared by syncContainerOrder and read_membership.php (issue #61). Returns null
- // on a corrupt persisted shape so callers fail closed instead of fataling mid-compute.
- function fv3_compute_folder_membership(array $folders, array $allContainerNames, array $ctLabels): ?array {
- foreach ($folders as $folder) {
- if (!is_array($folder) || !is_array($folder['containers'] ?? [])
- || (isset($folder['name']) && !is_string($folder['name']))) { return null; }
- }
+ // truth shared by syncContainerOrder and read_membership.php (issue #61).
+ function fv3_compute_folder_membership(array $folders, array $allContainerNames, array $ctLabels): array {
+ // One malformed entry is dropped or filled in, not allowed to abort every folder's sync
+ $folders = fv3_normalize_folders($folders);
$folderNameSet = [];
foreach ($folders as $folder) {
if (isset($folder['name'])) { $folderNameSet[$folder['name']] = true; }
@@ -600,11 +653,6 @@ function syncContainerOrder(string $type): void {
return;
}
$membership = fv3_compute_folder_membership($folders, $allContainerNames, $ctLabels);
- // Corrupt persisted shapes fail closed before the autostart write, not fatal mid-sync
- if ($membership === null) {
- fv3_debug_log("syncContainerOrder: folder entry with invalid containers shape, aborting before write");
- return;
- }
$folderContainers = $membership['containers'];
$folderNames = $membership['names'];
$assignedContainers = $membership['assigned'];
@@ -738,10 +786,9 @@ function fv3_dedupe_explicit_members(array $folders, ?string $winnerId = null):
return $folders;
}
- function updateFolder(string $type, string $content, string $id = '') : void {
+ function updateFolder(string $type, string $content, ?string $id = null) : void {
global $configDir;
- if(!file_exists("$configDir/$type.json")) { createFile($type); if (empty($id)) $id = generateId(); }
- if(empty($id)) { $id = generateId(); }
+ if(!file_exists("$configDir/$type.json")) { createFile($type); }
$decoded = json_decode($content, true);
// A folder must be an object/array — reject scalars so a full-backup bundle can't pollute $type.json
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
@@ -763,6 +810,16 @@ function updateFolder(string $type, string $content, string $id = '') : void {
echo json_encode(['error' => "$type.json is unreadable — refusing to save so existing folders are not wiped"]);
exit;
}
+ // null (create.php) gets a fresh id, and so does an unknown bad id from a folder-map import; a stored bad id,
+ // '' included, is refused, never duplicated
+ if ($id === null || (!fv3_is_folder_id($id) && !array_key_exists($id, $fileData))) {
+ $id = generateId();
+ } elseif (!fv3_is_folder_id($id)) {
+ http_response_code(400);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => 'Folder id may only contain letters, digits, _ and -']);
+ exit;
+ }
$fileData[$id] = $decoded;
$fileData = fv3_dedupe_explicit_members($fileData, $id);
$path = "$configDir/$type.json";
@@ -785,8 +842,8 @@ function updateFolderIds(string $type, string $data) : void {
}
$changed = false;
foreach ($updates as $folderId => $patch) {
- if (!preg_match('/^[A-Za-z0-9+\/=]+$/', $folderId)) continue;
- if (!isset($fileData[$folderId])) continue;
+ if (!fv3_is_folder_id($folderId)) continue;
+ if (!is_array($fileData[$folderId] ?? null)) continue;
if (isset($patch['containers']) && is_array($patch['containers'])) {
$fileData[$folderId]['containers'] = $patch['containers'];
$changed = true;
@@ -823,6 +880,8 @@ function deleteFolder(string $type, string $id) : void {
echo json_encode(['error' => "$type.json is unreadable — refusing to delete so the folder config is not wiped"]);
exit;
}
+ // Already gone: delete stays idempotent, and there is nothing to rewrite
+ if (!array_key_exists($id, $fileData)) { return; }
unset($fileData[$id]);
$path = "$configDir/$type.json";
fv3_atomic_write($path, json_encode($fileData));
@@ -990,224 +1049,8 @@ function updateSettingsFreeform(string $key, string $value) : void {
@chmod($path, 0660);
}
- function listThemes() : array {
- global $configDir;
- $stylesDir = "$configDir/styles";
- if (!is_dir($stylesDir)) return [];
- $themes = [];
- foreach (scandir($stylesDir) as $entry) {
- if ($entry === '.' || $entry === '..') continue;
- $path = "$stylesDir/$entry";
- if (!is_dir($path)) {
- if (preg_match('/^_fv3-generated\./', $entry)) continue;
- if (!preg_match('/\.css$/', $entry)) continue;
- $disabled = false;
- $name = preg_replace('/\.css$/', '', $entry);
- } else {
- $disabled = (bool) preg_match('/\.disabled$/', $entry);
- $name = preg_replace('/\.disabled$/', '', $entry);
- }
- $source = null;
- if (is_dir($path)) {
- $srcFile = $path . '/.fv3-source';
- if (file_exists($srcFile)) {
- $raw = trim(file_get_contents($srcFile));
- $parsed = json_decode($raw, true);
- $source = is_array($parsed) ? $parsed : ['repo' => $raw];
- }
- }
- $themes[] = [
- 'name' => $name,
- 'entry' => $entry,
- 'isDir' => is_dir($path),
- 'enabled' => !$disabled,
- 'source' => $source
- ];
- }
- return $themes;
- }
-
- function toggleTheme(string $entry, bool $enable, bool $exclusive) : void {
- global $configDir;
- $stylesDir = "$configDir/styles";
- if (!preg_match('/^[a-zA-Z0-9._-]+$/', $entry) || $entry === '.' || $entry === '..') { http_response_code(400); exit; }
- $path = "$stylesDir/$entry";
- if (!file_exists($path)) { http_response_code(404); exit; }
- if ($exclusive && $enable) {
- foreach (scandir($stylesDir) as $e) {
- if ($e === '.' || $e === '..' || !is_dir("$stylesDir/$e")) continue;
- if (preg_match('/^_fv3-generated\./', $e)) continue;
- $ePath = "$stylesDir/$e";
- if (!preg_match('/\.disabled$/', $e) && $e !== $entry) {
- @rename($ePath, $ePath . '.disabled');
- }
- }
- }
- $isDisabled = (bool) preg_match('/\.disabled$/', $entry);
- if ($enable && $isDisabled) {
- $newName = preg_replace('/\.disabled$/', '', $entry);
- @rename($path, "$stylesDir/$newName");
- } else if (!$enable && !$isDisabled) {
- @rename($path, $path . '.disabled');
- }
- }
-
- function importTheme(string $repoUrl, string $subPath = '', string $branch = '') : array {
- global $configDir;
- $stylesDir = "$configDir/styles";
- $maxCssBytes = 2 * 1024 * 1024;
- $maxCssFiles = 200;
- if (!preg_match('#^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$#', $repoUrl)) {
- return ['error' => 'Invalid repo format. Use owner/repo.'];
- }
- $ctx = stream_context_create(['http' => [
- 'header' => "User-Agent: FolderView3\r\n",
- 'timeout' => 15
- ]]);
- $refParam = $branch !== '' ? '?ref=' . rawurlencode($branch) : '';
- $apiBase = "https://api.github.com/repos/$repoUrl/contents/";
- $fetchPath = ($subPath !== '' ? $apiBase . rawurlencode($subPath) : $apiBase) . $refParam;
- $raw = @file_get_contents($fetchPath, false, $ctx);
- if ($raw === false) return ['error' => 'Failed to fetch repo contents.'];
- $contents = json_decode($raw, true);
- if (!is_array($contents)) return ['error' => 'Invalid GitHub API response.'];
- $cssFiles = [];
- foreach ($contents as $f) {
- if (!isset($f['name']) || $f['type'] !== 'file') continue;
- if (preg_match('/\.css$/i', $f['name'])) $cssFiles[] = $f;
- }
- if ($subPath === '') {
- $dirs = array_filter($contents, fn($f) => isset($f['name']) && $f['type'] === 'dir');
- foreach ($dirs as $dir) {
- $subRaw = @file_get_contents($apiBase . rawurlencode($dir['name']) . $refParam, false, $ctx);
- if ($subRaw === false) continue;
- $subContents = json_decode($subRaw, true);
- if (!is_array($subContents)) continue;
- foreach ($subContents as $sf) {
- if (isset($sf['name']) && $sf['type'] === 'file' && preg_match('/\.css$/i', $sf['name'])) {
- $sf['_subdir'] = $dir['name'];
- $cssFiles[] = $sf;
- }
- }
- }
- }
- if (empty($cssFiles)) return ['error' => 'No CSS files found.'];
- $repoParts = explode('/', $repoUrl);
- $owner = $repoParts[0];
- $repoSlug = $repoParts[1];
- $baseName = $subPath !== '' ? "$owner-$subPath" : "$owner-$repoSlug";
- if ($branch !== '' && !in_array($branch, ['main', 'master'])) {
- $baseName .= "-$branch";
- }
- $themeName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $baseName);
- $themeDirEnabled = "$stylesDir/$themeName";
- $themeDirDisabled = "$stylesDir/$themeName.disabled";
- $isUpdate = is_dir($themeDirEnabled) || is_dir($themeDirDisabled);
- if (is_dir($themeDirEnabled)) $themeDir = $themeDirEnabled;
- elseif (is_dir($themeDirDisabled)) $themeDir = $themeDirDisabled;
- else $themeDir = $themeDirDisabled;
- if (is_dir($themeDir)) {
- $items = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($themeDir, RecursiveDirectoryIterator::SKIP_DOTS),
- RecursiveIteratorIterator::CHILD_FIRST
- );
- foreach ($items as $item) {
- if ($item->getFilename() === '.fv3-source') continue;
- if ($item->isDir()) @rmdir($item->getRealPath());
- else @unlink($item->getRealPath());
- }
- } else {
- @mkdir($themeDir, 0770, true);
- }
- $downloaded = [];
- $cssFiles = array_slice($cssFiles, 0, $maxCssFiles);
- foreach ($cssFiles as $file) {
- if (!isset($file['download_url'])) continue;
- $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $file['name']);
- $subdir = $file['_subdir'] ?? '';
- $targetDir = $themeDir;
- if ($subdir !== '') {
- $safeDir = preg_replace('/[^a-zA-Z0-9._-]/', '-', $subdir);
- $targetDir = "$themeDir/$safeDir";
- if (!is_dir($targetDir)) @mkdir($targetDir, 0770, true);
- }
- $css = @file_get_contents($file['download_url'], false, $ctx, 0, $maxCssBytes);
- if ($css !== false) {
- fv3_atomic_write("$targetDir/$safeName", $css);
- $downloaded[] = ($subdir !== '' ? "$safeDir/" : '') . $safeName;
- }
- }
- if (empty($downloaded)) return ['error' => 'Failed to download any CSS files.'];
- $warnings = fv3_scan_css_warnings($themeDir, $downloaded);
- $sourceData = ['repo' => $repoUrl, 'path' => $subPath, 'branch' => $branch, 'files' => []];
- foreach ($cssFiles as $file) {
- if (!isset($file['sha'])) continue;
- $subdir = $file['_subdir'] ?? '';
- $key = $subdir !== '' ? $subdir . '/' . $file['name'] : $file['name'];
- $sourceData['files'][$key] = $file['sha'];
- }
- $sourceData['updated'] = date('c');
- fv3_atomic_write("$themeDir/.fv3-source", json_encode($sourceData));
- $result = ['success' => true, 'name' => $themeName, 'files' => $downloaded, 'is_update' => $isUpdate];
- if (!empty($warnings)) $result['warnings'] = $warnings;
- return $result;
- }
-
- function fv3_scan_css_warnings(string $baseDir, array $relPaths): array {
- $warnings = [];
- $patterns = [
- 'url(' => '/url\s*\(/i',
- '@import' => '/@import\b/i',
- 'expression(' => '/expression\s*\(/i',
- 'javascript:' => '/javascript\s*:/i'
- ];
- foreach ($relPaths as $relPath) {
- $filePath = "$baseDir/$relPath";
- if (!file_exists($filePath)) continue;
- $lines = file($filePath, FILE_IGNORE_NEW_LINES);
- foreach ($lines as $lineNum => $line) {
- foreach ($patterns as $label => $regex) {
- if (preg_match($regex, $line)) {
- $trimmed = trim($line);
- if (strlen($trimmed) > 120) $trimmed = substr($trimmed, 0, 120) . '...';
- $warning = [
- 'file' => $relPath,
- 'line' => $lineNum + 1,
- 'type' => $label,
- 'code' => $trimmed
- ];
- if ($label === 'url(' && preg_match('/url\s*\(\s*["\']?([^"\')\s]+)/i', $line, $urlMatch)) {
- $warning['url'] = $urlMatch[1];
- }
- $warnings[] = $warning;
- }
- }
- }
- }
- return $warnings;
- }
-
- function deleteTheme(string $entry) : void {
- global $configDir;
- $stylesDir = "$configDir/styles";
- if (!preg_match('/^[a-zA-Z0-9._-]+$/', $entry) || $entry === '.' || $entry === '..') { http_response_code(400); exit; }
- $path = "$stylesDir/$entry";
- if (!file_exists($path)) { http_response_code(404); exit; }
- if (preg_match('/^_fv3-generated\./', $entry)) { http_response_code(403); exit; }
- if (is_dir($path)) {
- $items = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
- RecursiveIteratorIterator::CHILD_FIRST
- );
- foreach ($items as $item) {
- if ($item->isDir()) { @rmdir($item->getRealPath()); }
- else { @unlink($item->getRealPath()); }
- }
- @rmdir($path);
- } else {
- @unlink($path);
- }
- }
+ // Theme listing, import, toggle and delete, and the styles/ confinement helpers they share
+ require_once(__DIR__ . '/themes.php');
function exportAll() : array {
global $configDir;
@@ -1248,6 +1091,7 @@ function exportAll() : array {
if ($item->isFile() && (preg_match('/\.css$/i', $item->getFilename()) || $item->getFilename() === '.fv3-source')) {
if (preg_match('/^_fv3-generated\./', $item->getFilename())) continue;
$relPath = substr($item->getRealPath(), strlen(realpath($stylesDir)) + 1);
+ if (fv3_is_theme_scratch($relPath)) continue;
$content = file_get_contents($item->getRealPath());
$cssSize += strlen($content);
$bundle['custom_styles'][$relPath] = $content;
@@ -1309,29 +1153,26 @@ function importAll(string $json) : array {
}
continue;
}
- // Folder maps are id => folder — allowlist the id keys so a crafted backup can't
- // plant a folder id that breaks out of class/onclick attributes at render (XSS).
- // Alphanumeric ONLY: every generator (folder.view/2/3) strips +/= and never emits
- // them, and an id containing +/= would break jQuery selectors at render time.
+ // A key outside the id rule could break out of class/onclick markup (XSS): re-key it, keep the folder
if ($key === 'docker' || $key === 'vm') {
$clean = [];
foreach ($data as $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;
- }
+ if (!is_array($folder)) { continue; }
+ $clean[fv3_is_folder_id($fid) ? (string)$fid : generateId()] = $folder;
}
// 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);
}
+ // Same validation an interactive save gets, before it is written AND before it is generated
+ if ($key === 'css_config') { $data = fv3_sanitize_css_config($data); $bundle['css_config'] = $data; }
$filename = $key === 'css_config' ? 'css-config.json' : "$key.json";
$path = "$configDir/$filename";
$flags = JSON_PRETTY_PRINT;
if (empty($data)) $flags |= JSON_FORCE_OBJECT;
- fv3_atomic_write($path, json_encode($data, $flags));
+ if (!fv3_atomic_write($path, json_encode($data, $flags))) {
+ return ['error' => "Could not write $filename (earlier sections were imported)", 'restored' => $restored];
+ }
$restored[] = $filename;
}
if (isset($bundle['custom_styles']) && is_array($bundle['custom_styles'])) {
@@ -1343,16 +1184,19 @@ function importAll(string $json) : array {
if (!preg_match('/\.css$/i', $relPath) && basename($relPath) !== '.fv3-source') continue;
if (preg_match('/\.\./', $relPath)) continue;
$fullPath = "$stylesDir/$relPath";
- $dir = dirname($fullPath);
- if (!is_dir($dir)) @mkdir($dir, 0770, true);
- $dirReal = realpath($dir);
- if ($dirReal === false || strpos($dirReal, $baseReal) !== 0) continue;
- fv3_atomic_write($fullPath, $content);
+ if (!fv3_mkdir_within(dirname($fullPath), (string)$baseReal)) {
+ return ['error' => "Could not create the folder for styles/$relPath (earlier sections were imported)", 'restored' => $restored];
+ }
+ if (!fv3_atomic_write($fullPath, $content)) {
+ return ['error' => "Could not write styles/$relPath (earlier sections were imported)", 'restored' => $restored];
+ }
$restored[] = "styles/$relPath";
}
}
if (isset($bundle['css_config']) && is_array($bundle['css_config'])) {
- generateCssFile($bundle['css_config']);
+ if (!generateCssFile($bundle['css_config'])) {
+ return ['error' => 'Could not write the generated CSS (earlier sections were imported)', 'restored' => $restored];
+ }
}
// apply the restored folder layout / autostart mode to the live start order immediately
syncContainerOrder('docker');
@@ -1421,11 +1265,9 @@ function readCssDefaults() : array {
];
}
- function updateCssConfig(string $json) : void {
- global $configDir;
- if (strlen($json) > 51200) { http_response_code(400); echo 'Config too large'; exit; }
- $config = json_decode($json, true);
- if ($config === null) { http_response_code(400); echo 'Invalid JSON'; exit; }
+ // Allowlisted keys, stripped custom CSS, scalar variable values: the CSS endpoint and a restore
+ // must not disagree about what is storable
+ function fv3_sanitize_css_config(array $config) : array {
$allowedKeys = ['preset', 'global', 'dashboard', 'docker', 'vm', 'custom_css', 'custom_css_dashboard', 'custom_css_docker', 'custom_css_vm', 'toggle_style', 'custom_presets', 'user_notes', 'page_presets', 'page_values'];
foreach (array_keys($config) as $k) {
if (!in_array($k, $allowedKeys, true)) { unset($config[$k]); }
@@ -1445,30 +1287,44 @@ function updateCssConfig(string $json) : void {
}
}
}
+ return $config;
+ }
+
+ function updateCssConfig(string $json) : void {
+ global $configDir;
+ if (strlen($json) > 51200) { http_response_code(400); echo 'Config too large'; exit; }
+ $config = json_decode($json, true);
+ if (json_last_error() !== JSON_ERROR_NONE || !is_array($config)) { http_response_code(400); echo 'Invalid JSON'; exit; }
+ $config = fv3_sanitize_css_config($config);
// Generate CSS file BEFORE object cast (generateCssFile expects arrays)
- generateCssFile($config);
+ // Generated first so a failed write leaves css-config.json unchanged; files already
+ // written can sit ahead of it until the next successful save
+ if (!generateCssFile($config)) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => 'Could not write the generated CSS files.']);
+ exit;
+ }
// Ensure map keys are always serialized as JSON objects (PHP encodes empty arrays as [])
foreach (['global', 'dashboard', 'docker', 'vm', 'page_presets', 'page_values'] as $mapKey) {
if (isset($config[$mapKey]) && is_array($config[$mapKey])) {
$config[$mapKey] = (object)$config[$mapKey];
}
}
- $path = "$configDir/css-config.json";
+ // Atomic write (temp + rename): a short write can't leave css-config.json truncated, and a reader never sees a torn file
if (!is_dir($configDir)) { @mkdir($configDir, 0770, true); }
- $fp = fopen($path, 'c+');
- if (!$fp) { http_response_code(500); exit; }
- flock($fp, LOCK_EX);
- ftruncate($fp, 0);
- rewind($fp);
- fwrite($fp, json_encode($config, JSON_PRETTY_PRINT));
- fflush($fp);
- flock($fp, LOCK_UN);
- fclose($fp);
- @chmod($path, 0660);
+ if (!fv3_atomic_write("$configDir/css-config.json", json_encode($config, JSON_PRETTY_PRINT))) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => 'Could not save the CSS configuration.']);
+ exit;
+ }
}
- function generateCssFile(array $config) : void {
+ // False when a generated file could not be written or removed
+ function generateCssFile(array $config) : bool {
global $configDir;
+ $ok = true;
$defaults = readCssDefaults();
$stylesDir = "$configDir/styles";
if (!is_dir($stylesDir)) { @mkdir($stylesDir, 0770, true); }
@@ -1509,9 +1365,9 @@ function generateCssFile(array $config) : void {
}
$outPath = "$stylesDir/_fv3-generated.docker-vm-dashboard.css";
if ($hasGlobal) {
- fv3_atomic_write($outPath, $globalCss);
+ $ok = fv3_atomic_write($outPath, $globalCss) && $ok;
} else {
- @unlink($outPath);
+ $ok = (!file_exists($outPath) || @unlink($outPath)) && $ok;
}
// Page-scoped variables + custom CSS → per-page files
@@ -1540,17 +1396,23 @@ function generateCssFile(array $config) : void {
$scopePath = "$stylesDir/_fv3-generated.{$scope}.css";
if ($hasScope) {
- fv3_atomic_write($scopePath, $scopeCss);
+ $ok = fv3_atomic_write($scopePath, $scopeCss) && $ok;
} else {
- @unlink($scopePath);
+ $ok = (!file_exists($scopePath) || @unlink($scopePath)) && $ok;
}
}
+ return $ok;
}
function generateId(int $length = 20) : string {
return substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes((int)ceil($length * 3 / 4)))), 0, $length);
}
+ // Ids safe to write and to render into class/selector/onclick markup; int = all-digit JSON key
+ function fv3_is_folder_id($id) : bool {
+ return (is_string($id) || is_int($id)) && preg_match('/^[A-Za-z0-9_-]+$/D', (string)$id) === 1;
+ }
+
function createFile(string $type): void {
global $configDir;
if (!is_dir($configDir)) { @mkdir($configDir, 0770, true); }
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/read_membership.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/read_membership.php
index 7552bde7..8d422300 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/read_membership.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/read_membership.php
@@ -41,11 +41,6 @@
exit;
}
$m = fv3_compute_folder_membership($folders, $allContainerNames, $ctLabels);
- if ($m === null) {
- http_response_code(500);
- echo json_encode(['error' => 'folder config contains an invalid containers shape']);
- exit;
- }
$out = [];
foreach ($m['containers'] as $placeholder => $members) {
foreach ($members as $ct) { $out[$ct] = $m['names'][$placeholder]; }
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/themes.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/themes.php
new file mode 100644
index 00000000..373a0afa
--- /dev/null
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/themes.php
@@ -0,0 +1,345 @@
+ 'The styles folder could not be read.']);
+ exit;
+ }
+ return $entries;
+ }
+
+ function listThemes() : array {
+ global $configDir;
+ $stylesDir = "$configDir/styles";
+ if (!is_dir($stylesDir)) return [];
+ $baseReal = (string)realpath($stylesDir);
+ $themes = [];
+ foreach (fv3_scan_styles($stylesDir) as $entry) {
+ if ($entry === '.' || $entry === '..' || fv3_is_theme_scratch($entry)) continue;
+ $path = "$stylesDir/$entry";
+ if (!is_dir($path)) {
+ if (preg_match('/^_fv3-generated\./', $entry)) continue;
+ // A disabled flat theme is foo.css.disabled; list it so it can be enabled again
+ if (!preg_match('/\.css(\.disabled)?$/i', $entry)) continue;
+ $disabled = (bool) preg_match('/\.disabled$/i', $entry);
+ $name = preg_replace('/\.css(\.disabled)?$/i', '', $entry);
+ } else {
+ $disabled = (bool) preg_match('/\.disabled$/i', $entry);
+ $name = preg_replace('/\.disabled$/i', '', $entry);
+ }
+ $source = null;
+ // A linked entry stays listed so it can be deleted, but nothing is read through a link
+ if (is_dir($path) && !is_link($path) && fv3_path_within($path, $baseReal)) {
+ $srcFile = $path . '/.fv3-source';
+ if (file_exists($srcFile) && !is_link($srcFile)) {
+ $raw = trim(file_get_contents($srcFile));
+ $parsed = json_decode($raw, true);
+ $source = is_array($parsed) ? $parsed : ['repo' => $raw];
+ }
+ }
+ $themes[] = [
+ 'name' => $name,
+ 'entry' => $entry,
+ 'isDir' => is_dir($path),
+ 'enabled' => !$disabled,
+ 'source' => $source
+ ];
+ }
+ return $themes;
+ }
+
+ // True when $path resolves inside $baseReal (itself a realpath); re-check right before a write or delete
+ function fv3_path_within(string $path, string $baseReal): bool {
+ $real = realpath($path);
+ return $real !== false && $baseReal !== '' && strpos($real . '/', rtrim($baseReal, '/') . '/') === 0;
+ }
+
+ // mkdir -p that can't lead out of $baseReal: the nearest existing ancestor (a dangling link counts) must resolve inside it
+ function fv3_mkdir_within(string $dir, string $baseReal): bool {
+ $probe = $dir;
+ while (!file_exists($probe) && !is_link($probe) && dirname($probe) !== $probe) { $probe = dirname($probe); }
+ if (!fv3_path_within($probe, $baseReal)) return false;
+ if (!is_dir($dir)) @mkdir($dir, 0770, true);
+ return is_dir($dir) && fv3_path_within($dir, $baseReal);
+ }
+
+ // Empties $dir without following a link out of $baseReal.
+ // False when anything could not be removed, so a partial clear is never reported as done.
+ function fv3_clear_tree(string $dir, string $baseReal): bool {
+ if (is_link($dir) || !is_dir($dir)) return false;
+ $ok = true;
+ $items = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($items as $item) {
+ $p = $item->getPathname();
+ if (is_link($p)) { $ok = @unlink($p) && $ok; continue; }
+ if (!fv3_path_within($p, $baseReal)) { $ok = false; continue; }
+ $ok = ($item->isDir() ? @rmdir($p) : @unlink($p)) && $ok;
+ }
+ return $ok;
+ }
+
+ // fv3_clear_tree, then $dir itself
+ function fv3_remove_tree(string $dir, string $baseReal): bool {
+ return fv3_clear_tree($dir, $baseReal) && @rmdir($dir);
+ }
+
+ // importTheme's staging and replaced-theme folders: never listed or exported as themes
+ function fv3_is_theme_scratch(string $entry): bool {
+ return (bool)preg_match('/^\.fv3-(stage|old)-/', $entry);
+ }
+
+ function toggleTheme(string $entry, bool $enable, bool $exclusive) : void {
+ global $configDir;
+ $stylesDir = "$configDir/styles";
+ if (!preg_match('/^[a-zA-Z0-9._-]+$/', $entry) || $entry === '.' || $entry === '..') { http_response_code(400); exit; }
+ $path = "$stylesDir/$entry";
+ if (!file_exists($path)) { http_response_code(404); exit; }
+ // Case-insensitive like custom.php's folder filter, so the listed state matches what actually loads
+ $isDisabled = (bool) preg_match('/\.disabled$/i', $entry);
+ $moves = [];
+ // Switching themes is for theme folders. Flat CSS files are custom CSS that custom.php layers over any
+ // theme, so they are deliberately left as they are
+ if ($exclusive && $enable && is_dir($path)) {
+ foreach (fv3_scan_styles($stylesDir) as $e) {
+ if ($e === '.' || $e === '..' || !is_dir("$stylesDir/$e")) continue;
+ if (preg_match('/^_fv3-generated\./', $e)) continue;
+ if (!preg_match('/\.disabled$/i', $e) && $e !== $entry) $moves[] = ["$stylesDir/$e", "$stylesDir/$e.disabled", $e];
+ }
+ }
+ if ($enable && $isDisabled) {
+ $moves[] = [$path, "$stylesDir/" . preg_replace('/\.disabled$/i', '', $entry), $entry];
+ } else if (!$enable && !$isDisabled) {
+ $moves[] = [$path, "$path.disabled", $entry];
+ }
+ // All or nothing: every destination must be free before the first rename, and a rename that still fails
+ // puts the earlier ones back
+ $blocked = array_column(array_filter($moves, fn($m) => file_exists($m[1]) || is_link($m[1])), 2);
+ $stuck = [];
+ if (!$blocked) {
+ $done = [];
+ foreach ($moves as $m) {
+ if (@rename($m[0], $m[1])) { $done[] = $m; continue; }
+ $blocked = [$m[2]];
+ foreach (array_reverse($done) as $d) {
+ if (!@rename($d[1], $d[0])) $stuck[] = $d[2];
+ }
+ break;
+ }
+ }
+ if ($blocked) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ $error = 'Could not rename: ' . implode(', ', $blocked);
+ if ($stuck) $error .= '. Could not restore: ' . implode(', ', $stuck);
+ echo json_encode(['error' => $error]);
+ exit;
+ }
+ }
+
+ function importTheme(string $repoUrl, string $subPath = '', string $branch = '') : array {
+ global $configDir;
+ $stylesDir = "$configDir/styles";
+ $maxCssBytes = 2 * 1024 * 1024;
+ $maxCssFiles = 200;
+ if (!preg_match('#^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$#', $repoUrl)) {
+ return ['error' => 'Invalid repo format. Use owner/repo.'];
+ }
+ $ctx = stream_context_create(['http' => [
+ 'header' => "User-Agent: FolderView3\r\n",
+ 'timeout' => 15
+ ]]);
+ $refParam = $branch !== '' ? '?ref=' . rawurlencode($branch) : '';
+ $apiBase = "https://api.github.com/repos/$repoUrl/contents/";
+ $fetchPath = ($subPath !== '' ? $apiBase . rawurlencode($subPath) : $apiBase) . $refParam;
+ $raw = @file_get_contents($fetchPath, false, $ctx);
+ if ($raw === false) return ['error' => 'Failed to fetch repo contents.'];
+ $contents = json_decode($raw, true);
+ if (!is_array($contents)) return ['error' => 'Invalid GitHub API response.'];
+ $cssFiles = [];
+ foreach ($contents as $f) {
+ if (!isset($f['name']) || $f['type'] !== 'file') continue;
+ if (preg_match('/\.css$/i', $f['name'])) $cssFiles[] = $f;
+ }
+ if ($subPath === '') {
+ $dirs = array_filter($contents, fn($f) => isset($f['name']) && $f['type'] === 'dir');
+ $failedDirs = [];
+ foreach ($dirs as $dir) {
+ $subRaw = @file_get_contents($apiBase . rawurlencode($dir['name']) . $refParam, false, $ctx);
+ if ($subRaw === false) { $failedDirs[] = $dir['name']; continue; }
+ $subContents = json_decode($subRaw, true);
+ if (!is_array($subContents)) { $failedDirs[] = $dir['name']; continue; }
+ foreach ($subContents as $sf) {
+ if (isset($sf['name']) && $sf['type'] === 'file' && preg_match('/\.css$/i', $sf['name'])) {
+ $sf['_subdir'] = $dir['name'];
+ $cssFiles[] = $sf;
+ }
+ }
+ }
+ // A dir we could not inspect might hold theme files; installing the rest would be a partial theme
+ if ($failedDirs) return ['error' => 'Could not inspect theme folders: ' . implode(', ', $failedDirs)];
+ }
+ if (empty($cssFiles)) return ['error' => 'No CSS files found.'];
+ // All or nothing, like the downloads: a theme over the cap is refused, not installed short
+ if (count($cssFiles) > $maxCssFiles) return ['error' => "This theme has more than $maxCssFiles CSS files."];
+ $repoParts = explode('/', $repoUrl);
+ $owner = $repoParts[0];
+ $repoSlug = $repoParts[1];
+ $baseName = $subPath !== '' ? "$owner-$subPath" : "$owner-$repoSlug";
+ if ($branch !== '' && !in_array($branch, ['main', 'master'])) {
+ $baseName .= "-$branch";
+ }
+ $themeName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $baseName);
+ $themeDirEnabled = "$stylesDir/$themeName";
+ $themeDirDisabled = "$stylesDir/$themeName.disabled";
+ $isUpdate = is_dir($themeDirEnabled) || is_dir($themeDirDisabled);
+ $themeDir = is_dir($themeDirEnabled) ? $themeDirEnabled : $themeDirDisabled;
+ if (!is_dir($stylesDir)) { @mkdir($stylesDir, 0770, true); }
+ $baseReal = (string)realpath($stylesDir);
+ // Never follow a linked theme folder out of styles/
+ if (is_link($themeDir) || ($isUpdate && !fv3_path_within($themeDir, $baseReal))) {
+ return ['error' => 'Theme folder resolves outside the styles directory.'];
+ }
+ // Files land in a hidden .disabled sibling (skipped by custom.php and listThemes) and replace
+ // the live theme only once every one is in, so a failed update leaves the old theme untouched
+ $stageDir = "$stylesDir/.fv3-stage-" . bin2hex(random_bytes(6)) . '.disabled';
+ if (!@mkdir($stageDir, 0770)) {
+ return ['error' => 'Could not create a staging folder for the theme.'];
+ }
+ $fail = function (string $message) use ($stageDir, $baseReal): array {
+ fv3_remove_tree($stageDir, $baseReal);
+ return ['error' => $message];
+ };
+ $downloaded = [];
+ $missing = [];
+ $clashes = [];
+ $seen = [];
+ $stageReal = (string)realpath($stageDir);
+ foreach ($cssFiles as $file) {
+ $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $file['name']);
+ $subdir = $file['_subdir'] ?? '';
+ $targetDir = $stageDir;
+ if ($subdir !== '') {
+ $safeDir = preg_replace('/[^a-zA-Z0-9._-]/', '-', $subdir);
+ $targetDir = "$stageDir/$safeDir";
+ if (!is_dir($targetDir)) @mkdir($targetDir, 0770, true);
+ }
+ $relPath = ($subdir !== '' ? "$safeDir/" : '') . $safeName;
+ // Two GitHub names can clean up to one file name, and the later would overwrite the earlier
+ if (isset($seen[$relPath])) { $clashes[] = ($subdir !== '' ? "$subdir/" : '') . $file['name']; continue; }
+ $seen[$relPath] = true;
+ // A listed file with no download link fails the import like any other file that can't be fetched
+ if (!is_string($file['download_url'] ?? null)) { $missing[] = "$relPath (no download link)"; continue; }
+ // One byte past the cap tells a too-large file from one that fits exactly
+ $css = @file_get_contents($file['download_url'], false, $ctx, 0, $maxCssBytes + 1);
+ if ($css !== false && strlen($css) > $maxCssBytes) {
+ $missing[] = "$relPath (over 2 MB)";
+ // Re-confine right before the write: a swapped-in link since mkdir must not send the file outside staging
+ } elseif ($css !== false && fv3_path_within($targetDir, $stageReal) && fv3_atomic_write("$targetDir/$safeName", $css)) {
+ $downloaded[] = $relPath;
+ } else {
+ $missing[] = $relPath;
+ }
+ }
+ if ($clashes) return $fail('These files would overwrite another once their names are cleaned up: ' . implode(', ', $clashes));
+ if ($missing) return $fail(($downloaded ? 'Could not download: ' : 'Failed to download any CSS files: ') . implode(', ', $missing));
+ if (empty($downloaded)) return $fail('Failed to download any CSS files.');
+ $warnings = fv3_scan_css_warnings($stageDir, $downloaded);
+ $sourceData = ['repo' => $repoUrl, 'path' => $subPath, 'branch' => $branch, 'files' => []];
+ foreach ($cssFiles as $file) {
+ if (!isset($file['sha'])) continue;
+ $subdir = $file['_subdir'] ?? '';
+ $key = $subdir !== '' ? $subdir . '/' . $file['name'] : $file['name'];
+ $sourceData['files'][$key] = $file['sha'];
+ }
+ $sourceData['updated'] = date('c');
+ if (!fv3_atomic_write("$stageDir/.fv3-source", json_encode($sourceData))) {
+ return $fail('Could not write the theme source file.');
+ }
+ // Swap: old theme aside, staged copy in (rolled back if that fails), then drop the old copy
+ $oldDir = "$stylesDir/.fv3-old-" . bin2hex(random_bytes(6)) . '.disabled';
+ if ($isUpdate && !@rename($themeDir, $oldDir)) {
+ return $fail('Could not replace the old theme files.');
+ }
+ if (!@rename($stageDir, $themeDir)) {
+ // A failed restore leaves the only copy of the old theme in a scratch folder every surface hides, so name it
+ if ($isUpdate && !@rename($oldDir, $themeDir)) {
+ fv3_debug_log("importTheme: could not restore $oldDir to $themeDir");
+ return $fail('Could not install the theme files. The previous theme is still on the flash drive as styles/'
+ . basename($oldDir) . ' and has to be renamed back by hand.');
+ }
+ return $fail('Could not install the theme files.');
+ }
+ if ($isUpdate && !fv3_remove_tree($oldDir, $baseReal)) {
+ fv3_debug_log("importTheme: left $oldDir behind after updating $themeName");
+ }
+ // entry is the installed folder name — an update keeps an enabled theme unsuffixed, and the UI deletes by entry
+ $result = ['success' => true, 'name' => $themeName, 'entry' => basename($themeDir), 'files' => $downloaded, 'is_update' => $isUpdate];
+ if (!empty($warnings)) $result['warnings'] = $warnings;
+ return $result;
+ }
+
+ function fv3_scan_css_warnings(string $baseDir, array $relPaths): array {
+ $warnings = [];
+ $patterns = [
+ 'url(' => '/url\s*\(/i',
+ '@import' => '/@import\b/i',
+ 'expression(' => '/expression\s*\(/i',
+ 'javascript:' => '/javascript\s*:/i'
+ ];
+ foreach ($relPaths as $relPath) {
+ $filePath = "$baseDir/$relPath";
+ // Advisory scan only (url() is allowed by design): skip a file we can't read rather than crash or block the install
+ $lines = @file($filePath, FILE_IGNORE_NEW_LINES);
+ if ($lines === false) continue;
+ foreach ($lines as $lineNum => $line) {
+ foreach ($patterns as $label => $regex) {
+ if (preg_match($regex, $line)) {
+ $trimmed = trim($line);
+ if (strlen($trimmed) > 120) $trimmed = substr($trimmed, 0, 120) . '...';
+ $warning = [
+ 'file' => $relPath,
+ 'line' => $lineNum + 1,
+ 'type' => $label,
+ 'code' => $trimmed
+ ];
+ if ($label === 'url(' && preg_match('/url\s*\(\s*["\']?([^"\')\s]+)/i', $line, $urlMatch)) {
+ $warning['url'] = $urlMatch[1];
+ }
+ $warnings[] = $warning;
+ }
+ }
+ }
+ }
+ return $warnings;
+ }
+
+ function deleteTheme(string $entry) : void {
+ global $configDir;
+ $stylesDir = "$configDir/styles";
+ if (!preg_match('/^[a-zA-Z0-9._-]+$/', $entry) || $entry === '.' || $entry === '..') { http_response_code(400); exit; }
+ $path = "$stylesDir/$entry";
+ // is_link: a link whose target is gone fails file_exists() but must stay deletable
+ if (!file_exists($path) && !is_link($path)) { http_response_code(404); exit; }
+ if (preg_match('/^_fv3-generated\./', $entry)) { http_response_code(403); exit; }
+ // A linked entry is removed itself, never followed; anything else must resolve inside styles/
+ if (is_link($path)) {
+ $removed = @unlink($path);
+ } else {
+ $baseReal = (string)realpath($stylesDir);
+ if (!fv3_path_within($path, $baseReal)) { http_response_code(403); exit; }
+ $removed = is_dir($path) ? fv3_remove_tree($path, $baseReal) : @unlink($path);
+ }
+ if (!$removed) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => 'Some theme files could not be removed.']);
+ exit;
+ }
+ }
+?>
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/toggle_theme.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/toggle_theme.php
index 79487d64..52bd8e30 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/toggle_theme.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/toggle_theme.php
@@ -1,7 +1,12 @@
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update.php
index 49b7d69a..a4a33081 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update.php
@@ -2,9 +2,6 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
$type = fv3_validate_type($_POST['type'] ?? '');
- $id = $_POST['id'] ?? '';
- if (!empty($id) && !preg_match('/^[A-Za-z0-9+\/=]+$/', $id)) {
- $id = '';
- }
- updateFolder($type, $_POST['content'], $id);
+ // A missing id is refused (create.php is the endpoint that makes new folders); updateFolder() owns the rule for its value
+ updateFolder($type, fv3_post_string('content'), fv3_post_required('id'));
?>
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_autostart.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_autostart.php
index d329d0e4..21649587 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_autostart.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_autostart.php
@@ -2,9 +2,9 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
header('Content-Type: application/json');
- $mode = $_POST['mode'] ?? '';
- $sequence = json_decode($_POST['sequence'] ?? '[]', true);
- $waits = json_decode($_POST['waits'] ?? '{}', true);
+ $mode = fv3_post_string('mode');
+ $sequence = json_decode(fv3_post_required('sequence'), true);
+ $waits = json_decode(fv3_post_string('waits', '{}'), true);
if (!is_array($sequence) || !is_array($waits)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid payload']);
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_css_config.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_css_config.php
index bad12d96..c6230308 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_css_config.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_css_config.php
@@ -1,7 +1,7 @@
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_ids.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_ids.php
index b6965336..630c61b6 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_ids.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_ids.php
@@ -2,5 +2,5 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
$type = fv3_validate_type($_POST['type'] ?? '');
- updateFolderIds($type, $_POST['data'] ?? '{}');
+ updateFolderIds($type, fv3_post_string('data', '{}'));
?>
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_organizer_registry.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_organizer_registry.php
index e0c0d18a..8e089372 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_organizer_registry.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_organizer_registry.php
@@ -2,7 +2,7 @@
require_once("/usr/local/emhttp/plugins/folder.view3/server/lib.php");
fv3_post_init();
header('Content-Type: application/json');
- $folders = json_decode($_POST['folders'] ?? '[]', true);
+ $folders = json_decode(fv3_post_required('folders'), true);
if (!is_array($folders)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid payload']);
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_settings.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_settings.php
index 8579d369..de08f81d 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_settings.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/server/update_settings.php
@@ -1,8 +1,8 @@
'Invalid type.']); exit;
- }
- global $configDir;
- $stylesDir = "$configDir/styles";
- if (!is_dir($stylesDir)) { @mkdir($stylesDir, 0770, true); }
- if ($type === 'css') {
- if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
- echo json_encode(['error' => 'Upload failed.']); exit;
- }
- $file = $_FILES['file'];
- if ($file['size'] > 2 * 1024 * 1024) {
- echo json_encode(['error' => 'File too large (max 2 MB).']); exit;
- }
- if (strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'css') {
- echo json_encode(['error' => 'Only .css files are accepted.']); exit;
- }
- $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '-', pathinfo($file['name'], PATHINFO_FILENAME)) . '.css';
- $dest = "$stylesDir/$safeName.disabled";
- if (!move_uploaded_file($file['tmp_name'], $dest)) {
- echo json_encode(['error' => 'Failed to save file.']); exit;
- }
- @chmod($dest, 0660);
- $warnings = fv3_scan_css_warnings(dirname($dest), [basename($dest)]);
- $result = ['success' => true, 'name' => pathinfo($safeName, PATHINFO_FILENAME)];
- if (!empty($warnings)) $result['warnings'] = $warnings;
- echo json_encode($result);
- } else {
- $folderName = $_POST['folder_name'] ?? '';
- if (!preg_match('/^[a-zA-Z0-9._-]+$/', $folderName)) {
- echo json_encode(['error' => 'Invalid folder name.']); exit;
- }
- $files = $_FILES['files'] ?? [];
- $paths = $_POST['paths'] ?? [];
- if (empty($files['name'])) {
- echo json_encode(['error' => 'No files received.']); exit;
- }
- $destDir = "$stylesDir/$folderName.disabled";
- if (!is_dir($destDir)) { @mkdir($destDir, 0770, true); }
- $maxSize = 2 * 1024 * 1024;
- $maxFiles = 200;
- $saved = 0;
- $processed = 0;
- $realDest = realpath($destDir);
- foreach ($files['name'] as $i => $name) {
- if ($files['error'][$i] !== UPLOAD_ERR_OK) continue;
- if (++$processed > $maxFiles) break;
- if (($files['size'][$i] ?? 0) > $maxSize) continue;
- if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'css') continue;
- $relPath = isset($paths[$i]) ? $paths[$i] : $name;
- $safeParts = array_map(fn($p) => preg_replace('/[^a-zA-Z0-9._-]/', '-', $p), explode('/', $relPath));
- $safeParts = array_values(array_filter($safeParts, fn($p) => $p !== '' && $p !== '.' && $p !== '..'));
- $safePath = implode('/', $safeParts);
- // Re-validate the extension on the ACTUAL on-disk name — paths[] is independent of the .css-checked upload name
- if ($safePath === '' || strtolower(pathinfo($safePath, PATHINFO_EXTENSION)) !== 'css') continue;
- $targetPath = "$destDir/$safePath";
- $targetParent = dirname($targetPath);
- if (!is_dir($targetParent)) { @mkdir($targetParent, 0770, true); }
- // Re-confine the resolved parent under $destDir before writing (defence-in-depth vs traversal)
- $realParent = realpath($targetParent);
- if ($realDest === false || $realParent === false || strpos($realParent . '/', $realDest . '/') !== 0) continue;
- if (move_uploaded_file($files['tmp_name'][$i], $targetPath)) {
- @chmod($targetPath, 0660);
- $saved++;
- }
- }
- if ($saved === 0) {
- @rmdir($destDir);
- echo json_encode(['error' => 'No CSS files were uploaded.']); exit;
- }
- $savedPaths = [];
- $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($destDir, RecursiveDirectoryIterator::SKIP_DOTS));
- foreach ($iter as $item) {
- if ($item->isFile() && preg_match('/\.css$/i', $item->getFilename())) {
- $savedPaths[] = substr($item->getPathname(), strlen($destDir) + 1);
- }
- }
- $warnings = fv3_scan_css_warnings($destDir, $savedPaths);
- $result = ['success' => true, 'name' => $folderName, 'files' => $saved];
- if (!empty($warnings)) $result['warnings'] = $warnings;
- echo json_encode($result);
- }
-?>
diff --git a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/styles/custom.php b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/styles/custom.php
index 039c89f6..51e69358 100644
--- a/src/folder.view3/usr/local/emhttp/plugins/folder.view3/styles/custom.php
+++ b/src/folder.view3/usr/local/emhttp/plugins/folder.view3/styles/custom.php
@@ -1,7 +1,7 @@