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 @@ ? '' : ''} ${repo ? '' : ''} - + `; 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 = ''; + btnBar.innerHTML = ''; 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 = `
${escapeHtml(folder.name)}
${$.i18n('stopped')}
${$.i18n('up-to-date')}
${$.i18n('force-update')}
0%

0 / 0
`; + const fld = `
${escapeHtml(folder.name)}
${$.i18n('stopped')}
${$.i18n('up-to-date')}
${$.i18n('force-update')}
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 = `
${escapeHtml(folder.name)}folder-${id}
${$.i18n('stopped')}
`; + const fld = `
${escapeHtml(folder.name)}folder-${escapeHtml(id)}
${$.i18n('stopped')}
`; 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 @@