From c595bf38588fdb51f8de713fadd3cae257edfe26 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 2 Sep 2026 23:41:45 +0200 Subject: [PATCH] feat(chunks): keep chunk code in files, deprecate the database column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chunk's code now lives in views/chunks/.html. The file is the chunk if it exists; the site_htmlsnippets column is what a chunk nobody has saved since holds, and the mirror the manager's search still reads. Both readers - the parser on demand and the site cache builder, which short circuits it - go through one rule, so a cached chunk does not render the stale column. No column, no setting and no migration: a site crosses over one save at a time and one that saves nothing keeps rendering what it always did. Every database path is marked @deprecated since 3.5.8 with @todo [remove@3.7], so `artisan deprecated:list` collects them. Under views/, not assets/: the root .htaccess passes ^assets/ straight to the filesystem, and chunks are where older extras keep configuration. Verified against Apache - a file under assets/chunks came back 200 with its contents, the same file under views/ came back 403. The directory ships with deny guards, and the store writes them wherever it is pointed. Chunk names are prose, so hazardous bytes are percent-encoded rather than the name refused, and decoded back exactly: "nav/main" is nav%2Fmain.html, "CON" is %43ON.html, "Кнопка" stays readable. What encoding cannot fix is refused with a reason - invalid UTF-8, not NFC, too long once escaped, or a name that would share one file with another chunk on a case-insensitive filesystem. The form previews the same encoding the store applies. Renaming or deleting a chunk takes its files with it; one left behind would be adopted silently by the next chunk named the same. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P9k7KBwVFZ6PSWH27ePZuJ --- core/config/view.php | 27 + core/lang/az/global.php | 8 + core/lang/be/global.php | 8 + core/lang/bg/global.php | 8 + core/lang/cs/global.php | 8 + core/lang/da/global.php | 8 + core/lang/de/global.php | 8 + core/lang/en/global.php | 8 + core/lang/es/global.php | 8 + core/lang/fa/global.php | 8 + core/lang/fi/global.php | 8 + core/lang/fr/global.php | 8 + core/lang/he/global.php | 8 + core/lang/it/global.php | 8 + core/lang/ja/global.php | 8 + core/lang/nl/global.php | 8 + core/lang/nn/global.php | 8 + core/lang/pl/global.php | 8 + core/lang/pt/global.php | 8 + core/lang/ru/global.php | 8 + core/lang/sv/global.php | 8 + core/lang/uk/global.php | 8 + core/lang/zh/global.php | 8 + core/src/Controllers/Chunk.php | 24 +- core/src/Legacy/Cache.php | 11 +- core/src/Models/SiteHtmlsnippet.php | 4 + core/src/Parser.php | 44 +- core/src/Support/ChunkFileStore.php | 460 ++++++++++++++++++ .../tests/Unit/Manager/ChunkFileStoreTest.php | 157 ++++++ .../Manager/ChunkFilenameEncodingTest.php | 150 ++++++ .../Unit/Manager/ChunkFilesWiringTest.php | 208 ++++++++ .../Unit/Security/ChunkFileExposureTest.php | 101 ++++ core/vendor/composer/autoload_classmap.php | 1 + core/vendor/composer/autoload_static.php | 1 + .../delete_htmlsnippet.processor.php | 4 +- .../duplicate_htmlsnippet.processor.php | 7 + .../processors/save_htmlsnippet.processor.php | 117 ++++- manager/views/page/chunk.blade.php | 107 ++++ views/chunks/.htaccess | 2 + views/chunks/index.html | 2 + 40 files changed, 1583 insertions(+), 20 deletions(-) create mode 100644 core/src/Support/ChunkFileStore.php create mode 100644 core/tests/Unit/Manager/ChunkFileStoreTest.php create mode 100644 core/tests/Unit/Manager/ChunkFilenameEncodingTest.php create mode 100644 core/tests/Unit/Manager/ChunkFilesWiringTest.php create mode 100644 core/tests/Unit/Security/ChunkFileExposureTest.php create mode 100644 views/chunks/.htaccess create mode 100644 views/chunks/index.html diff --git a/core/config/view.php b/core/config/view.php index f14655deec..85171965c8 100644 --- a/core/config/view.php +++ b/core/config/view.php @@ -33,6 +33,33 @@ 'blade.php' => ['label' => 'Blade', 'processor' => null], 'php' => ['label' => 'PHP', 'processor' => null], ], + /* + |-------------------------------------------------------------------------- + | Chunk files + |-------------------------------------------------------------------------- + | + | Where a chunk keeps its code. + | + | Under views/, not assets/: the root .htaccess passes ^assets/ straight to + | the filesystem, and chunks hold extras' configuration, credentials + | included. views/ ships denied and already holds template files. + | (@FILE still searches assets/chunks/; existing files are not moved.) + | + | A chunk is HTML with placeholders some parser may know - the file decides + | neither which parser nor whether one runs. Hence one format, and a form + | that only offers the list once something adds a second entry: + | + | config(['view.chunk_formats' => array_merge( + | config('view.chunk_formats', []), + | ['tpl' => 'Template'] + | )]); + | + */ + 'chunk_path' => EVO_BASE_PATH . 'views/chunks/', + 'chunk_formats' => [ + 'html' => 'HTML', + ], + 'directive' => [ //---------- /** diff --git a/core/lang/az/global.php b/core/lang/az/global.php index a3fb7d4349..1130846e46 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -107,6 +107,14 @@ $_lang["htmlsnippet_title"] = 'Chunk yarat/redaktə et'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Chunk kodu (HTML)'; +$_lang["chunk_assigned_file"] = 'Uyğun chunk faylı'; +$_lang["chunk_file_unusable_name"] = 'bu ad fayl adı ola bilməz'; +$_lang["chunk_file_not_writable"] = '%s chunk faylı yazıla bilmədi, ona görə heç nə saxlanılmadı. Adı və chunk qovluğunun icazələrini yoxlayın.'; +$_lang["chunk_name_empty"] = 'Faylda saxlanılan chunk üçün ad lazımdır.'; +$_lang["chunk_name_not_utf8"] = 'Bu ad düzgün UTF-8 deyil, ona görə fayl adı ola bilməz.'; +$_lang["chunk_name_not_nfc"] = 'Bu adda birləşdirici simvollar var. Onu yenidən yazın, əks halda macOS onu eyni sözün başqa yazılışı ilə eyni fayl kimi qəbul edəcək.'; +$_lang["chunk_name_too_long"] = 'Xüsusi simvolları ekranlaşdırıldıqdan sonra bu ad fayl adı üçün çox uzundur.'; +$_lang["chunk_name_collides"] = 'Bu ad və «%s» chunk-u Windows və macOS-da eyni faylı paylaşardı, çünki onlar yalnız hərflərin registrinə görə fərqləndirir. Onlardan birini yenidən adlandırın.'; $_lang["chunk_multiple_id"] = 'Xəta: Bir neçə Chunk eyni unikal ID ilə paylaşılır.'; $_lang["chunk_no_exist"] = 'Chunk mövcud deyil.'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index 168a85087a..39f19e3ce8 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -95,6 +95,14 @@ $_lang["htmlsnippet_title"] = 'Стварыць/рэдагаваць чанк'; $_lang["chunk"] = 'Чанк'; $_lang["chunk_code"] = 'Код чанка (html)'; +$_lang["chunk_assigned_file"] = 'Адпаведны файл чанка'; +$_lang["chunk_file_unusable_name"] = 'гэтае імя не можа стаць імем файла'; +$_lang["chunk_file_not_writable"] = 'Не ўдалося запісаць файл чанка %s, таму нічога не захавана. Праверце імя і правы на каталог чанкаў.'; +$_lang["chunk_name_empty"] = 'Чанку, які захоўваецца ў файле, патрэбна імя.'; +$_lang["chunk_name_not_utf8"] = 'Гэтае імя не з\'яўляецца сапраўдным UTF-8, таму не можа стаць імем файла.'; +$_lang["chunk_name_not_nfc"] = 'У гэтым імені ёсць камбінаваныя сімвалы. Набярыце яго нанова, інакш macOS палічыць яго тым самым файлам, што і іншы запіс таго ж слова.'; +$_lang["chunk_name_too_long"] = 'Гэтае імя занадта доўгае для імя файла пасля экранавання спецыяльных сімвалаў.'; +$_lang["chunk_name_collides"] = 'Гэтае імя і чанк «%s» дзялілі б адзін файл у Windows і macOS, якія адрозніваюць іх толькі рэгістрам. Перайменуйце адзін з іх.'; $_lang["chunk_multiple_id"] = 'Памылка: некалькі чанкаў маюць адзін і той жа унікальны ідэнтыфікатар.'; $_lang["chunk_no_exist"] = 'Чанка не існуе.'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 12849c4650..e967e8434b 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -105,6 +105,14 @@ $_lang["charset_title"] = 'Character encoding'; $_lang["chunk"] = 'Чънк'; $_lang["chunk_code"] = 'Код на Чънка (html)'; +$_lang["chunk_assigned_file"] = 'Съответстващ файл на чънка'; +$_lang["chunk_file_unusable_name"] = 'това име не може да стане име на файл'; +$_lang["chunk_file_not_writable"] = 'Файлът на чънка %s не можа да бъде записан, затова нищо не беше запазено. Проверете името и правата на директорията с чънкове.'; +$_lang["chunk_name_empty"] = 'Чънк, който се съхранява във файл, се нуждае от име.'; +$_lang["chunk_name_not_utf8"] = 'Това име не е валиден UTF-8, затова не може да стане име на файл.'; +$_lang["chunk_name_not_nfc"] = 'Това име използва комбиниращи знаци. Въведете го отново, иначе macOS ще го приеме за същия файл като друг изпис на същата дума.'; +$_lang["chunk_name_too_long"] = 'Това име е твърде дълго за име на файл, след като специалните му знаци бъдат екранирани.'; +$_lang["chunk_name_collides"] = 'Това име и чънкът „%s“ биха споделяли един файл под Windows и macOS, които ги различават само по големина на буквите. Преименувайте един от тях.'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = 'Изчистване'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index c0432c5849..445ce4bc39 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -105,6 +105,14 @@ $_lang["charset_title"] = 'Kódování znaků:'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Kód chunku (html)'; +$_lang["chunk_assigned_file"] = 'Odpovídající soubor chunku'; +$_lang["chunk_file_unusable_name"] = 'z tohoto názvu nelze udělat název souboru'; +$_lang["chunk_file_not_writable"] = 'Soubor chunku %s se nepodařilo zapsat, takže se nic neuložilo. Zkontrolujte název a práva adresáře chunků.'; +$_lang["chunk_name_empty"] = 'Chunk uložený v souboru potřebuje název.'; +$_lang["chunk_name_not_utf8"] = 'Tento název není platné UTF-8, takže z něj nelze udělat název souboru.'; +$_lang["chunk_name_not_nfc"] = 'Tento název používá kombinující znaky. Napište jej znovu, jinak jej macOS bude považovat za stejný soubor jako jiný zápis téhož slova.'; +$_lang["chunk_name_too_long"] = 'Tento název je po escapování speciálních znaků příliš dlouhý na název souboru.'; +$_lang["chunk_name_collides"] = 'Tento název a chunk „%s“ by na Windows a macOS sdílely jeden soubor, protože je rozlišují jen podle velikosti písmen. Jeden z nich přejmenujte.'; $_lang["chunk_multiple_id"] = 'Chyba: Více Chunků sdílí unikátní ID.'; $_lang["chunk_no_exist"] = 'Chunk neexistuje.'; $_lang["cleaningup"] = 'Čistím / Odstraňuji'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 09aade39da..336881a41a 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -106,6 +106,14 @@ $_lang["charset_title"] = 'Karakter encoding:'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Chunk kode (html)'; +$_lang["chunk_assigned_file"] = 'Tilhørende chunk-fil'; +$_lang["chunk_file_unusable_name"] = 'dette navn kan ikke blive et filnavn'; +$_lang["chunk_file_not_writable"] = 'Chunk-filen %s kunne ikke skrives, så intet blev gemt. Kontrollér navnet og rettighederne til chunk-mappen.'; +$_lang["chunk_name_empty"] = 'En chunk, der gemmes i en fil, skal have et navn.'; +$_lang["chunk_name_not_utf8"] = 'Dette navn er ikke gyldig UTF-8 og kan derfor ikke blive et filnavn.'; +$_lang["chunk_name_not_nfc"] = 'Dette navn bruger kombinerende tegn. Skriv det igen, ellers behandler macOS det som samme fil som en anden stavemåde af samme ord.'; +$_lang["chunk_name_too_long"] = 'Dette navn er for langt til et filnavn, når dets specialtegn er escapet.'; +$_lang["chunk_name_collides"] = 'Dette navn og chunken "%s" ville dele én fil på Windows og macOS, som kun skelner dem ved store og små bogstaver. Omdøb en af dem.'; $_lang["chunk_multiple_id"] = 'Fejl: Flere chunks har det samme unikke ID.'; $_lang["chunk_no_exist"] = 'Chunk eksisterer ikke.'; $_lang["cleaningup"] = 'Rydder op'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index ea3e4f1f78..75eb7c13c3 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -109,6 +109,14 @@ $_lang["charset_title"] = 'Zeichenkodierung'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Chunk-Code (HTML)'; +$_lang["chunk_assigned_file"] = 'Zugehörige Chunk-Datei'; +$_lang["chunk_file_unusable_name"] = 'dieser Name kann kein Dateiname werden'; +$_lang["chunk_file_not_writable"] = 'Die Chunk-Datei %s konnte nicht geschrieben werden, daher wurde nichts gespeichert. Prüfen Sie den Namen und die Rechte des Chunk-Verzeichnisses.'; +$_lang["chunk_name_empty"] = 'Ein Chunk, der in einer Datei liegt, braucht einen Namen.'; +$_lang["chunk_name_not_utf8"] = 'Dieser Name ist kein gültiges UTF-8 und kann daher kein Dateiname werden.'; +$_lang["chunk_name_not_nfc"] = 'Dieser Name enthält kombinierende Zeichen. Geben Sie ihn neu ein, sonst behandelt macOS ihn als dieselbe Datei wie eine andere Schreibweise desselben Wortes.'; +$_lang["chunk_name_too_long"] = 'Dieser Name ist zu lang für einen Dateinamen, sobald seine Sonderzeichen maskiert sind.'; +$_lang["chunk_name_collides"] = 'Dieser Name und der Chunk „%s“ würden sich unter Windows und macOS eine Datei teilen, die nur nach Groß- und Kleinschreibung unterscheiden. Benennen Sie einen von beiden um.'; $_lang["chunk_multiple_id"] = 'Fehler: Mehrere Chunks benutzen die selbe ID.'; $_lang["chunk_no_exist"] = 'Chunk existiert nicht.'; $_lang["cleaningup"] = 'Aufräumen'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 700353862d..cb32995b41 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -109,6 +109,14 @@ $_lang["charset_title"] = 'Character encoding'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Chunk code (html)'; +$_lang["chunk_assigned_file"] = 'Corresponding chunk file'; +$_lang["chunk_file_unusable_name"] = 'this name cannot become a filename'; +$_lang["chunk_file_not_writable"] = 'The chunk file %s could not be written, so nothing was saved. Check the name and the permissions of the chunk directory.'; +$_lang["chunk_name_empty"] = 'A chunk kept in a file needs a name.'; +$_lang["chunk_name_not_utf8"] = 'This name is not valid UTF-8, so it cannot become a filename.'; +$_lang["chunk_name_not_nfc"] = 'This name uses combining characters. Retype it, or macOS will treat it as the same file as another spelling of the same word.'; +$_lang["chunk_name_too_long"] = 'This name is too long to be a filename once its special characters are escaped.'; +$_lang["chunk_name_collides"] = 'This name and the chunk "%s" would share one file on Windows and macOS, which tell them apart only by case. Rename one of them.'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = 'Cleaning up'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index 7405452e1e..5480c72246 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -109,6 +109,14 @@ $_lang["charset_title"] = 'Codificación del caracteres:'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Código de Chunk (html)'; +$_lang["chunk_assigned_file"] = 'Archivo de chunk correspondiente'; +$_lang["chunk_file_unusable_name"] = 'este nombre no puede ser un nombre de archivo'; +$_lang["chunk_file_not_writable"] = 'No se pudo escribir el archivo de chunk %s, por lo que no se guardó nada. Compruebe el nombre y los permisos del directorio de chunks.'; +$_lang["chunk_name_empty"] = 'Un chunk guardado en un archivo necesita un nombre.'; +$_lang["chunk_name_not_utf8"] = 'Este nombre no es UTF-8 válido, por lo que no puede ser un nombre de archivo.'; +$_lang["chunk_name_not_nfc"] = 'Este nombre usa caracteres combinantes. Vuelva a escribirlo o macOS lo tratará como el mismo archivo que otra escritura de la misma palabra.'; +$_lang["chunk_name_too_long"] = 'Este nombre es demasiado largo para un nombre de archivo una vez escapados sus caracteres especiales.'; +$_lang["chunk_name_collides"] = 'Este nombre y el chunk «%s» compartirían un mismo archivo en Windows y macOS, que solo los distinguen por mayúsculas y minúsculas. Cambie el nombre de uno de ellos.'; $_lang["chunk_multiple_id"] = 'Error: Varios Chunks comparten la misma ID única.'; $_lang["chunk_no_exist"] = 'El Chunk no existe.'; $_lang["cleaningup"] = 'Limpiando'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index b79cc23ca2..ddc2bdfbb6 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -106,6 +106,14 @@ $_lang["charset_title"] = 'کاراکترست متون'; $_lang["chunk"] = 'چانک'; $_lang["chunk_code"] = 'کد چانک (HTML)'; +$_lang["chunk_assigned_file"] = 'فایل چانک متناظر'; +$_lang["chunk_file_unusable_name"] = 'این نام نمی‌تواند نام فایل باشد'; +$_lang["chunk_file_not_writable"] = 'فایل چانک %s نوشته نشد، بنابراین چیزی ذخیره نشد. نام و مجوزهای پوشهٔ چانک‌ها را بررسی کنید.'; +$_lang["chunk_name_empty"] = 'چانکی که در یک فایل نگهداری می‌شود به نام نیاز دارد.'; +$_lang["chunk_name_not_utf8"] = 'این نام UTF-8 معتبر نیست، بنابراین نمی‌تواند نام فایل باشد.'; +$_lang["chunk_name_not_nfc"] = 'این نام از نویسه‌های ترکیبی استفاده می‌کند. آن را دوباره تایپ کنید، وگرنه macOS آن را همان فایلِ املای دیگری از همان واژه در نظر می‌گیرد.'; +$_lang["chunk_name_too_long"] = 'این نام پس از گریز دادن نویسه‌های ویژه، برای نام فایل بسیار بلند است.'; +$_lang["chunk_name_collides"] = 'این نام و چانک «%s» در ویندوز و macOS یک فایل مشترک می‌شدند، چون آن‌ها را فقط با بزرگی و کوچکی حروف تشخیص می‌دهند. نام یکی از آن‌ها را تغییر دهید.'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = 'در حال تمیز کردن'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index 9fe6e6fea3..c49a5a74ff 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -105,6 +105,14 @@ $_lang["charset_title"] = 'Merkistö:'; $_lang["chunk"] = 'Palanen'; $_lang["chunk_code"] = 'Lähdekoodi (html)'; +$_lang["chunk_assigned_file"] = 'Vastaava chunk-tiedosto'; +$_lang["chunk_file_unusable_name"] = 'tästä nimestä ei voi tulla tiedostonimeä'; +$_lang["chunk_file_not_writable"] = 'Chunk-tiedostoa %s ei voitu kirjoittaa, joten mitään ei tallennettu. Tarkista nimi ja chunk-hakemiston oikeudet.'; +$_lang["chunk_name_empty"] = 'Tiedostossa säilytettävä chunk tarvitsee nimen.'; +$_lang["chunk_name_not_utf8"] = 'Tämä nimi ei ole kelvollista UTF-8:aa, joten siitä ei voi tulla tiedostonimeä.'; +$_lang["chunk_name_not_nfc"] = 'Tämä nimi käyttää yhdistäviä merkkejä. Kirjoita se uudelleen, tai macOS pitää sitä samana tiedostona kuin saman sanan toista kirjoitusasua.'; +$_lang["chunk_name_too_long"] = 'Tämä nimi on liian pitkä tiedostonimeksi, kun sen erikoismerkit on suojattu.'; +$_lang["chunk_name_collides"] = 'Tämä nimi ja chunk ”%s” jakaisivat saman tiedoston Windowsissa ja macOS:ssä, jotka erottavat ne vain kirjainkoosta. Nimeä toinen uudelleen.'; $_lang["chunk_multiple_id"] = 'Virhe: Usealla HTML-palasella on sama yksilötunnus (ID).'; $_lang["chunk_no_exist"] = 'HTML-palasta ei ole.'; $_lang["cleaningup"] = 'Odota hetki...'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index 7aadd9e2aa..79703506d6 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -108,6 +108,14 @@ $_lang["charset_title"] = 'Encodage des caractères:'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Code source du Chunk (HTML)'; +$_lang["chunk_assigned_file"] = 'Fichier du Chunk correspondant'; +$_lang["chunk_file_unusable_name"] = 'ce nom ne peut pas devenir un nom de fichier'; +$_lang["chunk_file_not_writable"] = 'Le fichier du Chunk %s n\'a pas pu être écrit, rien n\'a donc été enregistré. Vérifiez le nom et les droits du répertoire des Chunks.'; +$_lang["chunk_name_empty"] = 'Un Chunk conservé dans un fichier a besoin d\'un nom.'; +$_lang["chunk_name_not_utf8"] = 'Ce nom n\'est pas de l\'UTF-8 valide, il ne peut donc pas devenir un nom de fichier.'; +$_lang["chunk_name_not_nfc"] = 'Ce nom utilise des caractères combinants. Ressaisissez-le, sinon macOS le traitera comme le même fichier qu\'une autre graphie du même mot.'; +$_lang["chunk_name_too_long"] = 'Ce nom est trop long pour un nom de fichier une fois ses caractères spéciaux échappés.'; +$_lang["chunk_name_collides"] = 'Ce nom et le Chunk « %s » partageraient un même fichier sous Windows et macOS, qui ne les distinguent que par la casse. Renommez l\'un des deux.'; $_lang["chunk_multiple_id"] = 'Erreur: Plusieurs Chunks possèdent le même ID.'; $_lang["chunk_no_exist"] = 'Le Chunk n\'existe pas.'; $_lang["cleaningup"] = 'Nettoyage'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index e70634e842..4f0c6d64c3 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -106,6 +106,14 @@ $_lang["charset_title"] = 'קידוד:'; $_lang["chunk"] = 'מיני תבנית'; $_lang["chunk_code"] = 'קוד מיני תבנית (html)'; +$_lang["chunk_assigned_file"] = 'קובץ מיני התבנית המתאים'; +$_lang["chunk_file_unusable_name"] = 'שם זה אינו יכול להיות שם קובץ'; +$_lang["chunk_file_not_writable"] = 'לא ניתן היה לכתוב את קובץ מיני התבנית %s, ולכן דבר לא נשמר. בדקו את השם ואת ההרשאות של תיקיית מיני התבניות.'; +$_lang["chunk_name_empty"] = 'למיני תבנית הנשמרת בקובץ דרוש שם.'; +$_lang["chunk_name_not_utf8"] = 'שם זה אינו UTF-8 תקין, ולכן אינו יכול להיות שם קובץ.'; +$_lang["chunk_name_not_nfc"] = 'שם זה משתמש בתווים משולבים. הקלידו אותו מחדש, אחרת macOS יתייחס אליו כאל אותו קובץ ככתיב אחר של אותה מילה.'; +$_lang["chunk_name_too_long"] = 'שם זה ארוך מדי לשם קובץ לאחר שהתווים המיוחדים שבו מקודדים.'; +$_lang["chunk_name_collides"] = 'שם זה ומיני התבנית "%s" היו חולקים קובץ אחד ב-Windows וב-macOS, המבחינים ביניהם רק לפי אותיות גדולות וקטנות. שנו את שמו של אחד מהם.'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = 'מרענן נתונים'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index d36be00f12..63f125c6c1 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -109,6 +109,14 @@ $_lang["charset_title"] = 'Codifica caratteri:'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Codice del Chunk (HTML)'; +$_lang["chunk_assigned_file"] = 'File del Chunk corrispondente'; +$_lang["chunk_file_unusable_name"] = 'questo nome non può diventare un nome di file'; +$_lang["chunk_file_not_writable"] = 'Non è stato possibile scrivere il file del Chunk %s, quindi non è stato salvato nulla. Controlla il nome e i permessi della cartella dei Chunk.'; +$_lang["chunk_name_empty"] = 'Un Chunk conservato in un file ha bisogno di un nome.'; +$_lang["chunk_name_not_utf8"] = 'Questo nome non è UTF-8 valido, quindi non può diventare un nome di file.'; +$_lang["chunk_name_not_nfc"] = 'Questo nome usa caratteri combinanti. Riscrivilo, altrimenti macOS lo tratterà come lo stesso file di un\'altra grafia della stessa parola.'; +$_lang["chunk_name_too_long"] = 'Questo nome è troppo lungo per un nome di file una volta che i caratteri speciali sono stati preceduti da escape.'; +$_lang["chunk_name_collides"] = 'Questo nome e il Chunk «%s» condividerebbero un unico file su Windows e macOS, che li distinguono solo per maiuscole e minuscole. Rinomina uno dei due.'; $_lang["chunk_multiple_id"] = 'Errore: Diversi Chunks hanno lo stesso ID.'; $_lang["chunk_no_exist"] = 'Il Chunk non esiste.'; $_lang["cleaningup"] = 'Sto ripulendo'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 924f7f4a1c..82c9565644 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -112,6 +112,14 @@ $_lang["charset_title"] = 'システムエンコード'; $_lang["chunk"] = 'チャンク'; $_lang["chunk_code"] = 'チャンク コード (html)'; +$_lang["chunk_assigned_file"] = '対応するチャンクファイル'; +$_lang["chunk_file_unusable_name"] = 'この名前はファイル名にできません'; +$_lang["chunk_file_not_writable"] = 'チャンクファイル %s を書き込めなかったため、何も保存されませんでした。名前とチャンクディレクトリの権限を確認してください。'; +$_lang["chunk_name_empty"] = 'ファイルに保存するチャンクには名前が必要です。'; +$_lang["chunk_name_not_utf8"] = 'この名前は正しい UTF-8 ではないため、ファイル名にできません。'; +$_lang["chunk_name_not_nfc"] = 'この名前は結合文字を使用しています。入力し直してください。そのままでは macOS が同じ語の別の表記と同一のファイルとして扱います。'; +$_lang["chunk_name_too_long"] = 'この名前は特殊文字をエスケープするとファイル名として長すぎます。'; +$_lang["chunk_name_collides"] = 'この名前とチャンク「%s」は Windows と macOS で同じファイルを共有してしまいます。これらは大文字と小文字だけで名前を区別します。どちらかの名前を変更してください。'; $_lang["chunk_multiple_id"] = 'エラー: 複数のチャンクが一つのユニークIDを利用しています。'; $_lang["chunk_no_exist"] = 'チャンクが存在しません。'; $_lang['chunk_processor'] = 'チャンクを処理するクラス'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 3e719ae7d5..676faad5cd 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -109,6 +109,14 @@ $_lang["charset_title"] = 'Karakterset codering'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Chunk code (html)'; +$_lang["chunk_assigned_file"] = 'Bijbehorend Chunk-bestand'; +$_lang["chunk_file_unusable_name"] = 'deze naam kan geen bestandsnaam worden'; +$_lang["chunk_file_not_writable"] = 'Het Chunk-bestand %s kon niet worden geschreven, dus er is niets opgeslagen. Controleer de naam en de rechten van de Chunk-map.'; +$_lang["chunk_name_empty"] = 'Een Chunk die in een bestand staat, heeft een naam nodig.'; +$_lang["chunk_name_not_utf8"] = 'Deze naam is geen geldige UTF-8 en kan dus geen bestandsnaam worden.'; +$_lang["chunk_name_not_nfc"] = 'Deze naam gebruikt combinerende tekens. Typ hem opnieuw, anders behandelt macOS hem als hetzelfde bestand als een andere schrijfwijze van hetzelfde woord.'; +$_lang["chunk_name_too_long"] = 'Deze naam is te lang voor een bestandsnaam zodra de speciale tekens zijn geëscaped.'; +$_lang["chunk_name_collides"] = 'Deze naam en de Chunk "%s" zouden op Windows en macOS één bestand delen; die onderscheiden ze alleen op hoofdletters. Hernoem er een.'; $_lang["chunk_multiple_id"] = 'Fout: Meerdere Chunks delen deze unieke ID.'; $_lang["chunk_no_exist"] = 'Chunk bestaat niet.'; $_lang["cleaningup"] = 'Opschonen'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index f3e70d3a1f..4ef7043bc5 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -105,6 +105,14 @@ $_lang["charset_title"] = 'Tegnkoding'; $_lang["chunk"] = 'Html-stykke'; $_lang["chunk_code"] = 'Htmlstykke-kode (html)'; +$_lang["chunk_assigned_file"] = 'Tilhøyrande htmlstykke-fil'; +$_lang["chunk_file_unusable_name"] = 'dette namnet kan ikkje bli eit filnamn'; +$_lang["chunk_file_not_writable"] = 'Htmlstykke-fila %s kunne ikkje skrivast, så ingenting vart lagra. Kontroller namnet og rettane til htmlstykke-katalogen.'; +$_lang["chunk_name_empty"] = 'Eit htmlstykke som ligg i ei fil, treng eit namn.'; +$_lang["chunk_name_not_utf8"] = 'Dette namnet er ikkje gyldig UTF-8, og kan difor ikkje bli eit filnamn.'; +$_lang["chunk_name_not_nfc"] = 'Dette namnet brukar kombinerande teikn. Skriv det inn på nytt, elles handsamar macOS det som same fila som ei anna skrivemåte av same ordet.'; +$_lang["chunk_name_too_long"] = 'Dette namnet er for langt til eit filnamn når spesialteikna er escapa.'; +$_lang["chunk_name_collides"] = 'Dette namnet og htmlstykket «%s» ville dele éi fil på Windows og macOS, som berre skil dei med store og små bokstavar. Gi eitt av dei nytt namn.'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = 'Rydder opp'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index fe8d9d5fb5..698d3dd48c 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -110,6 +110,14 @@ $_lang["charset_title"] = 'Kodowanie znaków'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Kod chunka (html)'; +$_lang["chunk_assigned_file"] = 'Odpowiadający plik chunka'; +$_lang["chunk_file_unusable_name"] = 'ta nazwa nie może stać się nazwą pliku'; +$_lang["chunk_file_not_writable"] = 'Nie udało się zapisać pliku chunka %s, więc nic nie zostało zapisane. Sprawdź nazwę i uprawnienia katalogu chunków.'; +$_lang["chunk_name_empty"] = 'Chunk przechowywany w pliku potrzebuje nazwy.'; +$_lang["chunk_name_not_utf8"] = 'Ta nazwa nie jest poprawnym UTF-8, więc nie może stać się nazwą pliku.'; +$_lang["chunk_name_not_nfc"] = 'Ta nazwa używa znaków składających. Wpisz ją ponownie, inaczej macOS potraktuje ją jak ten sam plik co inny zapis tego samego słowa.'; +$_lang["chunk_name_too_long"] = 'Ta nazwa jest za długa na nazwę pliku po zamianie znaków specjalnych na sekwencje.'; +$_lang["chunk_name_collides"] = 'Ta nazwa i chunk „%s” dzieliłyby jeden plik w systemach Windows i macOS, które rozróżniają je tylko wielkością liter. Zmień nazwę jednego z nich.'; $_lang["chunk_multiple_id"] = 'Błąd: Wiele chunków posiada to samo ID.'; $_lang["chunk_no_exist"] = 'Chunk nie istnieje.'; $_lang["cleaningup"] = 'Czyszczenie'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index 4e6321a038..99e26b5b98 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -105,6 +105,14 @@ $_lang["charset_title"] = 'Codificação de caracteres:'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Código do Chunk (html)'; +$_lang["chunk_assigned_file"] = 'Ficheiro do Chunk correspondente'; +$_lang["chunk_file_unusable_name"] = 'este nome não pode ser um nome de ficheiro'; +$_lang["chunk_file_not_writable"] = 'Não foi possível escrever o ficheiro do Chunk %s, por isso nada foi guardado. Verifique o nome e as permissões do diretório dos Chunks.'; +$_lang["chunk_name_empty"] = 'Um Chunk guardado num ficheiro precisa de um nome.'; +$_lang["chunk_name_not_utf8"] = 'Este nome não é UTF-8 válido, por isso não pode ser um nome de ficheiro.'; +$_lang["chunk_name_not_nfc"] = 'Este nome usa caracteres combinantes. Escreva-o de novo, caso contrário o macOS tratá-lo-á como o mesmo ficheiro que outra grafia da mesma palavra.'; +$_lang["chunk_name_too_long"] = 'Este nome é demasiado longo para um nome de ficheiro depois de os caracteres especiais serem escapados.'; +$_lang["chunk_name_collides"] = 'Este nome e o Chunk «%s» partilhariam um mesmo ficheiro no Windows e no macOS, que apenas os distinguem por maiúsculas e minúsculas. Mude o nome de um deles.'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = 'Limpar'; diff --git a/core/lang/ru/global.php b/core/lang/ru/global.php index 3b25b96197..12be600c3b 100644 --- a/core/lang/ru/global.php +++ b/core/lang/ru/global.php @@ -110,6 +110,14 @@ $_lang["charset_title"] = 'Кодировка:'; $_lang["chunk"] = 'Чанк (HTML-элемент шаблона)'; $_lang["chunk_code"] = 'Код чанка (HTML)'; +$_lang["chunk_assigned_file"] = 'Соответствующий файл чанка'; +$_lang["chunk_file_unusable_name"] = 'это имя не может стать именем файла'; +$_lang["chunk_file_not_writable"] = 'Не удалось записать файл чанка %s, поэтому ничего не сохранено. Проверьте имя и права на каталог чанков.'; +$_lang["chunk_name_empty"] = 'Чанку, который хранится в файле, нужно имя.'; +$_lang["chunk_name_not_utf8"] = 'Это имя не является корректным UTF-8, поэтому не может стать именем файла.'; +$_lang["chunk_name_not_nfc"] = 'В имени есть комбинированные символы. Наберите его заново, иначе macOS сочтёт его тем же файлом, что и другую запись того же слова.'; +$_lang["chunk_name_too_long"] = 'Имя слишком длинное, чтобы стать именем файла после экранирования спецсимволов.'; +$_lang["chunk_name_collides"] = 'Это имя и чанк «%s» делили бы один файл на Windows и macOS — они различают имена без учёта регистра. Переименуйте одно из них.'; $_lang["chunk_multiple_id"] = 'Ошибка: Несколько чанков имеют одинаковый идентификатор.'; $_lang["chunk_no_exist"] = 'Чанк не найден.'; $_lang['chunk_processor'] = 'Класс обработки чанков'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index 146da59e03..b4c66f626a 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -106,6 +106,14 @@ $_lang["charset_title"] = 'Teckenkodning'; $_lang["chunk"] = 'Chunk'; $_lang["chunk_code"] = 'Chunk-kod (html)'; +$_lang["chunk_assigned_file"] = 'Motsvarande chunk-fil'; +$_lang["chunk_file_unusable_name"] = 'det här namnet kan inte bli ett filnamn'; +$_lang["chunk_file_not_writable"] = 'Chunk-filen %s kunde inte skrivas, så ingenting sparades. Kontrollera namnet och rättigheterna för chunk-katalogen.'; +$_lang["chunk_name_empty"] = 'En chunk som sparas i en fil behöver ett namn.'; +$_lang["chunk_name_not_utf8"] = 'Det här namnet är inte giltig UTF-8 och kan därför inte bli ett filnamn.'; +$_lang["chunk_name_not_nfc"] = 'Det här namnet använder kombinerande tecken. Skriv det igen, annars behandlar macOS det som samma fil som en annan stavning av samma ord.'; +$_lang["chunk_name_too_long"] = 'Det här namnet är för långt för ett filnamn när specialtecknen har escapats.'; +$_lang["chunk_name_collides"] = 'Det här namnet och chunken ”%s” skulle dela samma fil på Windows och macOS, som bara skiljer dem åt med versaler och gemener. Byt namn på en av dem.'; $_lang["chunk_multiple_id"] = 'Fel: Flera chunks har samma unika ID.'; $_lang["chunk_no_exist"] = 'Chunken existerar inte.'; $_lang["cleaningup"] = 'Rensar upp'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 90776756ad..0c1fea174e 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -110,6 +110,14 @@ $_lang["charset_title"] = 'Кодування:'; $_lang["chunk"] = 'Чанк (HTML-елемент шаблону)'; $_lang["chunk_code"] = 'Код чанку (HTML)'; +$_lang["chunk_assigned_file"] = 'Відповідний файл чанка'; +$_lang["chunk_file_unusable_name"] = 'це ім\'я не може стати іменем файлу'; +$_lang["chunk_file_not_writable"] = 'Не вдалося записати файл чанка %s, тому нічого не збережено. Перевірте ім\'я і права на каталог чанків.'; +$_lang["chunk_name_empty"] = 'Чанк, що зберігається у файлі, потребує імені.'; +$_lang["chunk_name_not_utf8"] = 'Це ім\'я не є коректним UTF-8, тому не може стати іменем файлу.'; +$_lang["chunk_name_not_nfc"] = 'В імені є комбіновані символи. Наберіть його заново, інакше macOS вважатиме його тим самим файлом, що й інший запис того самого слова.'; +$_lang["chunk_name_too_long"] = 'Ім\'я задовге, щоб стати іменем файлу після екранування спецсимволів.'; +$_lang["chunk_name_collides"] = 'Це ім\'я і чанк «%s» ділили б один файл на Windows і macOS — вони розрізняють імена без урахування регістру. Перейменуйте одне з них.'; $_lang["chunk_multiple_id"] = 'Помилка: Кілька чанків мають однаковий ідентифікатор.'; $_lang["chunk_no_exist"] = 'Чанк не знайдено.'; $_lang['chunk_processor'] = 'Клас обробки чанків'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index aeede52a16..e0874ed0f6 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -106,6 +106,14 @@ $_lang["charset_title"] = '编码:'; $_lang["chunk"] = '块元素'; $_lang["chunk_code"] = '块元素代码 (html)'; +$_lang["chunk_assigned_file"] = '对应的块元素文件'; +$_lang["chunk_file_unusable_name"] = '此名称无法作为文件名'; +$_lang["chunk_file_not_writable"] = '无法写入块元素文件 %s,因此未保存任何内容。请检查名称以及块元素目录的权限。'; +$_lang["chunk_name_empty"] = '保存在文件中的块元素需要一个名称。'; +$_lang["chunk_name_not_utf8"] = '此名称不是有效的 UTF-8,因此无法作为文件名。'; +$_lang["chunk_name_not_nfc"] = '此名称使用了组合字符。请重新输入,否则 macOS 会将其视为与同一单词的另一种写法相同的文件。'; +$_lang["chunk_name_too_long"] = '转义特殊字符后,此名称对于文件名来说过长。'; +$_lang["chunk_name_collides"] = '此名称与块元素“%s”在 Windows 和 macOS 上会共用同一个文件,这两个系统仅按大小写区分名称。请重命名其中之一。'; $_lang["chunk_multiple_id"] = 'Error: Multiple Chunks share the same unique ID.'; $_lang["chunk_no_exist"] = 'Chunk does not exist.'; $_lang["cleaningup"] = '清除'; diff --git a/core/src/Controllers/Chunk.php b/core/src/Controllers/Chunk.php index 438261f8dc..575174edbf 100644 --- a/core/src/Controllers/Chunk.php +++ b/core/src/Controllers/Chunk.php @@ -2,12 +2,16 @@ use EvolutionCMS\Models; use EvolutionCMS\Interfaces\ManagerTheme; +use EvolutionCMS\Support\ChunkFileStore; use Illuminate\Support\Collection; class Chunk extends AbstractController implements ManagerTheme\PageControllerInterface { protected $view = 'page.chunk'; + /** A chunk is not this big; half a megabyte does not belong in a form. */ + private const MAX_EDITABLE_FILE_BYTES = 524288; + protected $events = [ 'OnChunkFormPrerender', 'OnChunkFormRender', @@ -54,13 +58,31 @@ public function canView(): bool public function process() : bool { $this->object = $this->parameterData(); + $store = ChunkFileStore::make(); + $name = (string) $this->object->name; + + // Same rule the front end renders by: the file, else the column. + // + // @deprecated since 3.5.8 falling back to the column + // @todo [remove@3.7] Remove in Evolution CMS 3.7 + $extension = $store->writeExtension($name); + $fileContent = $store->read($name, $store->firstExisting($name)); + + if ($fileContent !== null) { + $this->object->snippet = $fileContent; + } + $this->parameters = [ 'data' => $this->object, 'categories' => $this->parameterCategories(), 'which_editor' => $this->which_editor, 'action' => $this->getIndex(), 'events' => $this->parameterEvents(), - 'actionButtons' => $this->parameterActionButtons() + 'actionButtons' => $this->parameterActionButtons(), + 'chunkFormats' => $store->all(), + 'chunkFormatDefault' => $extension, + 'chunkFileExisting' => $store->existing($name), + 'chunkFileDirectory' => $store->displayDirectory() ]; return true; diff --git a/core/src/Legacy/Cache.php b/core/src/Legacy/Cache.php index 2212eab663..7816d5a6fc 100644 --- a/core/src/Legacy/Cache.php +++ b/core/src/Legacy/Cache.php @@ -301,9 +301,18 @@ public function buildCache($evo) if (!isset($config['disable_chunk_cache']) || $config['disable_chunk_cache'] != 1) { // WRITE Chunks to cache file $chunks = Models\SiteHtmlsnippet::all(); + $chunkFiles = \EvolutionCMS\Support\ChunkFileStore::make(); $content .= '$c=&$this->chunkCache;'; foreach ($chunks->toArray() as $doc) { - $content .= '$c[\'' . $doc['name'] . '\']=\'' . ($doc['disabled'] ? '' : $this->escapeSingleQuotes($doc['snippet'])) . '\';'; + // What the front end reads: getBaseChunk() never runs for a + // cached chunk, so files are resolved here or not at all. + // + // @deprecated since 3.5.8 the $doc['snippet'] half + // @todo [remove@3.7] Remove in Evolution CMS 3.7 + $value = $doc['disabled'] + ? '' + : (string) $chunkFiles->resolve((string) $doc['name'], $doc['snippet']); + $content .= '$c[\'' . $doc['name'] . '\']=\'' . $this->escapeSingleQuotes($value) . '\';'; } } diff --git a/core/src/Models/SiteHtmlsnippet.php b/core/src/Models/SiteHtmlsnippet.php index 9eb8fd0559..50f40917e5 100644 --- a/core/src/Models/SiteHtmlsnippet.php +++ b/core/src/Models/SiteHtmlsnippet.php @@ -14,6 +14,10 @@ * @property int $category * @property bool $cache_type * @property string $snippet + * @deprecated since 3.5.8 A chunk's code belongs in views/chunks/.html. + * Kept for chunks nobody has saved yet, and as the mirror the manager's + * search reads. Nothing renders from it once a file exists. + * @todo [remove@3.7] Remove in Evolution CMS 3.7 * @property int $locked * @property int $createdon * @property int $editedon diff --git a/core/src/Parser.php b/core/src/Parser.php index 6ae641c67d..6a3325359a 100644 --- a/core/src/Parser.php +++ b/core/src/Parser.php @@ -1,26 +1,27 @@ where('disabled', '=', 0) ->get(); - $tpl = ($chunk->count() === 1) ? $chunk->first()->snippet : null; + $row = $chunk->count() === 1 ? $chunk->first() : null; + $tpl = $row === null + ? null + : ChunkFileStore::make()->resolve((string) $row->name, $row->snippet); $this->modx->chunkCache[$name] = $tpl; } diff --git a/core/src/Support/ChunkFileStore.php b/core/src/Support/ChunkFileStore.php new file mode 100644 index 0000000000..d8f3f726dd --- /dev/null +++ b/core/src/Support/ChunkFileStore.php @@ -0,0 +1,460 @@ +:\"/\\|?*"; + + /** Refused rather than truncated: a truncated name would not decode back. */ + private const MAX_FILENAME_BYTES = 255; + + /** Refused by Windows whatever extension follows: CON.html is uncreatable. */ + private const RESERVED_DEVICE_NAMES = [ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', + ]; + + /** @var array extension => label */ + private array $formats; + + /** Absolute, without a trailing separator. */ + private string $directory; + + /** + * @param array $formats extension => label + */ + public function __construct(array $formats, string $directory) + { + $normalised = []; + foreach ($formats as $extension => $label) { + $extension = ltrim((string) $extension, '.'); + if ($extension === '' || $this->sanitise($extension) !== $extension) { + continue; + } + + $normalised[$extension] = (string) (is_scalar($label) ? $label : $extension); + } + + $this->formats = $normalised; + $this->directory = rtrim(str_replace(chr(92), '/', $directory), '/'); + } + + public static function make(): self + { + $formats = []; + $directory = EVO_BASE_PATH . 'views/chunks/'; + + if (function_exists('config')) { + try { + $formats = (array) config('view.chunk_formats', []); + $directory = (string) config('view.chunk_path', $directory); + } catch (\Throwable) { + $formats = []; + } + } + + return new self($formats, $directory); + } + + /** + * @return array + */ + public function all(): array + { + return $this->formats; + } + + public function isRegistered(?string $extension): bool + { + return $extension !== null && isset($this->formats[ltrim($extension, '.')]); + } + + /** + * The extension the form preselects when a chunk has not recorded one. + */ + public function defaultExtension(): ?string + { + return $this->formats === [] ? null : (string) array_key_first($this->formats); + } + + /** + * The file a chunk name is written to, or null when it cannot be one. + * + * Names are prose, so hazardous bytes are percent-encoded rather than the + * name refused; nameFor() reverses it. Non-ASCII is left readable, which + * costs the NFC rule in refuseReason(). + */ + public function filename(string $name, string $extension): ?string + { + if (!$this->isRegistered($extension) || $this->refuseReason($name, $extension) !== null) { + return null; + } + + return $this->encode($name) . '.' . ltrim($extension, '.'); + } + + /** The inverse of filename(), or null if this store did not write it. */ + public function nameFor(string $filename, string $extension): ?string + { + $suffix = '.' . ltrim($extension, '.'); + + if (!str_ends_with($filename, $suffix)) { + return null; + } + + $encoded = substr($filename, 0, -strlen($suffix)); + + // Strict: a bare %, or one not followed by two hex digits, was never + // produced here and must not decode to something that looks close. + if ($encoded === '' || preg_match('/%(?![0-9A-Fa-f]{2})/', $encoded)) { + return null; + } + + $name = preg_replace_callback( + '/%([0-9A-Fa-f]{2})/', + static fn (array $m): string => chr((int) hexdec($m[1])), + $encoded + ); + + // Round trip or nothing: %2e and %2E both decode to '.', but only one + // of them is what this store writes, and only that one may be read as + // the chunk's file. + return $this->encode((string) $name) === $encoded ? $name : null; + } + + /** + * Why this name cannot be a file, or null when it can. + * + * What the encoding cannot fix. Each reason has its own fix, so the + * manager repeats it back rather than shrugging. + */ + public function refuseReason(string $name, string $extension): ?string + { + if (trim($name) === '') { + return 'empty'; + } + + if (preg_match('//u', $name) !== 1) { + return 'not_utf8'; + } + + // macOS compares NFC and NFD as one file, so two chunks that differ + // only in normal form would share it. + if (class_exists(\Normalizer::class) && !\Normalizer::isNormalized($name, \Normalizer::FORM_C)) { + return 'not_nfc'; + } + + // Percent-escapes cost three bytes each, and a name is up to 100 + // characters of anything. + if (strlen($this->encode($name) . '.' . ltrim($extension, '.')) > self::MAX_FILENAME_BYTES) { + return 'too_long'; + } + + return null; + } + + /** + * Key for the one clash the encoding cannot see: "MyChunk" and "mychunk" + * are both safe, both encode to themselves, and are one file on Windows + * and macOS. + */ + public function caseKey(string $name, string $extension): ?string + { + $filename = $this->filename($name, $extension); + + return $filename === null ? null : mb_strtolower($filename, 'UTF-8'); + } + + /** + * The other chunk this name would share a file with, or null. + * + * A UNIQUE index compares bytes; the filesystem folds case. Ask before + * writing. + * + * @param array $others names of every other chunk + */ + public function collidingName(string $name, string $extension, array $others): ?string + { + $key = $this->caseKey($name, $extension); + + if ($key === null) { + return null; + } + + foreach ($others as $other) { + if ((string) $other !== $name && $this->caseKey((string) $other, $extension) === $key) { + return (string) $other; + } + } + + return null; + } + + /** + * Percent-encode every filename hazard: the illegal bytes, a leading dot + * (hidden on unix), a trailing dot or space (dropped by Windows) and the + * reserved device names. + */ + private function encode(string $name): string + { + $out = ''; + $length = strlen($name); + + for ($i = 0; $i < $length; $i++) { + $byte = $name[$i]; + + // A list, not a character class: escaping a backslash through to + // the regex engine already lost it once. Only ASCII is listed, so + // multi-byte characters (>= 0x80) pass through. + $ord = ord($byte); + $out .= ($ord < 0x20 || $ord === 0x7F || strpos(self::UNSAFE_BYTES, $byte) !== false) + ? '%' . strtoupper(bin2hex($byte)) + : $byte; + } + + if (str_starts_with($out, '.')) { + $out = '%2E' . substr($out, 1); + } + + if (str_ends_with($out, '.') || str_ends_with($out, ' ')) { + $out = substr($out, 0, -1) . '%' . strtoupper(bin2hex(substr($out, -1))); + } + + if (in_array(strtoupper($out), self::RESERVED_DEVICE_NAMES, true)) { + $out = '%' . strtoupper(bin2hex($out[0])) . substr($out, 1); + } + + return $out; + } + + /** + * The path of a chunk's file, if it is there. + */ + public function pathFor(string $name, ?string $extension): ?string + { + if ($extension === null) { + return null; + } + + $filename = $this->filename($name, ltrim($extension, '.')); + if ($filename === null) { + return null; + } + + $candidate = $this->directory . '/' . $filename; + + return is_file($candidate) ? $candidate : null; + } + + /** + * Every format this chunk has a file for. + * + * @return array extension => absolute path + */ + public function existing(string $name): array + { + $found = []; + foreach (array_keys($this->formats) as $extension) { + $path = $this->pathFor($name, $extension); + if ($path !== null) { + $found[$extension] = $path; + } + } + + return $found; + } + + /** + * A chunk's code: its file, or the column it used to live in. + * + * No setting and nothing to migrate - a site crosses over one save at a + * time. Unlike a template, a same-named file winning is not an accident: + * views/chunks/ holds nothing else. + * + * @param string|null $stored the site_htmlsnippets column + * @deprecated since 3.5.8 Only so a chunk nobody has saved yet still + * renders. Without it, resolve() is "read the file". + * @todo [remove@3.7] Remove in Evolution CMS 3.7 + */ + public function resolve(string $name, ?string $stored): ?string + { + $extension = $this->firstExisting($name); + + if ($extension === null) { + return $stored; + } + + $content = $this->read($name, $extension); + + if ($content !== null) { + return $content; + } + + // Unreadable, not absent - permissions. The column keeps the page + // up; the log says it may be stale. + try { + Log::warning('Chunk file could not be read. The database copy was used instead.', [ + 'chunk' => $name, + 'extension' => $extension, + ]); + } catch (\Throwable) { + // No logger yet. Not worth a page for. + } + + return $stored; + } + + /** The format this chunk has a file in, in configured order, or null. */ + public function firstExisting(string $name): ?string + { + foreach (array_keys($this->formats) as $extension) { + if ($this->pathFor($name, $extension) !== null) { + return $extension; + } + } + + return null; + } + + /** + * The format a save writes to. Re-saving must not leave a second file + * beside the first, which would then win. + */ + public function writeExtension(string $name): ?string + { + return $this->firstExisting($name) ?? $this->defaultExtension(); + } + + /** + * Delete every format's file, not just the winning one: one left behind + * is adopted by the next chunk named the same. + */ + public function forget(string $name): void + { + foreach ($this->existing($name) as $path) { + @unlink($path); + } + } + + /** + * What a chunk's file holds, or null when there is no such file. + */ + public function read(string $name, ?string $extension): ?string + { + $path = $this->pathFor($name, $extension); + + if ($path === null || !is_readable($path)) { + return null; + } + + $content = file_get_contents($path); + + return $content === false ? null : $content; + } + + /** + * Put a chunk's code in its file, creating the file and the directory if + * they are not there yet. + * + * @return bool whether the file now holds this content + */ + public function write(string $name, ?string $extension, string $content): bool + { + $filename = $extension === null ? null : $this->filename($name, ltrim($extension, '.')); + if ($filename === null) { + return false; + } + + if (!$this->ensureDirectory()) { + return false; + } + + $path = $this->directory . '/' . $filename; + + if (is_file($path) ? !is_writable($path) : !is_writable($this->directory)) { + return false; + } + + return file_put_contents($path, $content) !== false; + } + + /** + * The directory chunk files are kept in. + */ + public function directory(): string + { + return $this->directory; + } + + /** + * Create the directory and its deny guards. + * + * views/ already denies, but the path is configurable and a parent + * .htaccess is one deploy away from being gone. Existing guards are kept. + */ + public function ensureDirectory(): bool + { + if (!is_dir($this->directory) + && !@mkdir($this->directory, 0777, true) + && !is_dir($this->directory)) { + return false; + } + + if (!is_writable($this->directory)) { + return false; + } + + foreach ([ + '.htaccess' => "order deny,allow\ndeny from all\n", + 'index.html' => "

Unauthorized access

\nYou're not allowed to access file folder", + ] as $guard => $body) { + if (!file_exists($this->directory . '/' . $guard)) { + @file_put_contents($this->directory . '/' . $guard, $body); + } + } + + return true; + } + + /** Relative to the installation: an absolute server path is nobody's business. */ + public function displayDirectory(): string + { + $base = rtrim(str_replace(chr(92), '/', EVO_BASE_PATH), '/') . '/'; + $directory = $this->directory . '/'; + + return strpos($directory, $base) === 0 ? substr($directory, strlen($base)) : $directory; + } + + /** The manager's label for a chunk's file, or null when it cannot be one. */ + public function displayPath(string $name, ?string $extension): ?string + { + $filename = $extension === null ? null : $this->filename($name, ltrim($extension, '.')); + + return $filename === null ? null : $this->displayDirectory() . $filename; + } + + /** + * What an extension may be made of. Extensions come from config, not from + * typing, so a hazardous one is a mistake to refuse rather than encode. + */ + private function sanitise(string $value): string + { + $value = (string) preg_replace('/\s+/', '', $value); + $value = (string) preg_replace('/[^a-zA-Z0-9_.-]+/', '', $value); + $value = (string) preg_replace('/\.{2,}/', '.', $value); + + return trim($value, '.'); + } +} diff --git a/core/tests/Unit/Manager/ChunkFileStoreTest.php b/core/tests/Unit/Manager/ChunkFileStoreTest.php new file mode 100644 index 0000000000..4d95e78472 --- /dev/null +++ b/core/tests/Unit/Manager/ChunkFileStoreTest.php @@ -0,0 +1,157 @@ + 'HTML', 'tpl' => 'Template', 'txt' => 'Text'], + $directory ?? (EVO_BASE_PATH . 'views/chunks/') + ); +} + +it('offers every declared format, and nothing else', function () { + $store = chunkFileStore(); + + expect(array_keys($store->all()))->toBe(['html', 'tpl', 'txt']) + ->and($store->defaultExtension())->toBe('html') + ->and($store->isRegistered('html'))->toBeTrue() + ->and($store->isRegistered('php'))->toBeFalse() + ->and($store->isRegistered('blade.php'))->toBeFalse() + ->and($store->isRegistered(null))->toBeFalse(); +}); + +it('keeps the dots that chunk names actually contain', function () { + // Unlike a template alias, "nav.main" is an ordinary chunk name, so a + // filename rule borrowed from templates would have refused most of them. + expect(chunkFileStore()->filename('nav.main', 'html'))->toBe('nav.main.html') + ->and(chunkFileStore()->filename('Header_2-col', 'tpl'))->toBe('Header_2-col.tpl'); +}); + +it('encodes a name rather than changing it', function () { + $store = chunkFileStore(); + + // Writing "myChunk.html" for a chunk named "my Chunk" would leave the two + // disagreeing about which file is read - so the space is escaped, not + // dropped, and nameFor() gives the name back exactly. + expect($store->filename('my Chunk', 'html'))->toBe('my Chunk.html') + ->and($store->filename('nav/main', 'html'))->toBe('nav%2Fmain.html') + ->and($store->nameFor('nav%2Fmain.html', 'html'))->toBe('nav/main') + ->and($store->filename('', 'html'))->toBeNull(); +}); + +it('cannot be talked out of the chunk directory', function () { + $store = chunkFileStore(); + + // Every separator becomes an escape and the leading dot with it, so a + // traversal is one filename with percent signs in it and nothing more. + expect($store->filename('../../index', 'html'))->toBe('%2E.%2F..%2Findex.html') + ->and($store->filename('..', 'html'))->toBe('%2E%2E.html') + ->and($store->filename('.htaccess', 'html'))->toBe('%2Ehtaccess.html') + // ... and it still names the chunk it came from, not a path. + ->and($store->nameFor('%2E.%2F..%2Findex.html', 'html'))->toBe('../../index') + ->and($store->pathFor('../../index', 'html'))->toBeNull(); +}); + +it('refuses a format nobody declared', function () { + // The extension is the other half of a filename under the web root. + $store = chunkFileStore(); + + expect($store->filename('nav', 'php'))->toBeNull() + ->and($store->pathFor('nav', 'php'))->toBeNull() + ->and($store->read('nav', 'php'))->toBeNull() + ->and($store->write('nav', 'php', 'x'))->toBeFalse(); +}); + +it('writes, reads back and lists the file it made', function () { + $directory = sys_get_temp_dir() . '/evo_chunks_' . bin2hex(random_bytes(6)); + $store = chunkFileStore($directory); + $name = 'nav.main'; + + try { + // The directory does not exist yet: creating it is part of the job. + expect($store->write($name, 'html', ''))->toBeTrue() + ->and($store->read($name, 'html'))->toBe('') + ->and(array_keys($store->existing($name)))->toBe(['html']) + ->and($store->read($name, 'tpl'))->toBeNull(); + } finally { + @unlink($directory . '/' . $name . '.html'); + @unlink($directory . '/.htaccess'); + @unlink($directory . '/index.html'); + @rmdir($directory); + } +}); + +it('names the file relative to the installation, never absolutely', function () { + // The manager shows this string; an absolute server path is not the + // operator's business and not theirs to paste anywhere. + $store = chunkFileStore(); + + expect($store->displayDirectory())->toBe('views/chunks/') + ->and($store->displayPath('nav.main', 'html'))->toBe('views/chunks/nav.main.html') + ->and($store->displayPath('nav/main', 'html'))->toBe('views/chunks/nav%2Fmain.html') + ->and($store->displayPath('', 'html'))->toBeNull(); +}); + +it('reads the file when there is one, and the column when there is not', function () { + $directory = sys_get_temp_dir() . '/evo_chunks_' . bin2hex(random_bytes(6)); + $store = chunkFileStore($directory); + + try { + // No file yet: a chunk that has not been saved since files existed + // renders exactly what it always rendered. + expect($store->resolve('nav', 'from the column'))->toBe('from the column') + ->and($store->resolve('nav', null))->toBeNull(); + + $store->write('nav', 'html', 'from the file'); + + expect($store->resolve('nav', 'from the column'))->toBe('from the file') + ->and($store->firstExisting('nav'))->toBe('html') + // Re-saving must go back to the same file, not put an .html + // beside a .tpl and change which one wins. + ->and($store->writeExtension('nav'))->toBe('html') + ->and($store->writeExtension('never.seen'))->toBe('html'); + } finally { + $store->forget('nav'); + @unlink($directory . '/.htaccess'); + @unlink($directory . '/index.html'); + @rmdir($directory); + } +}); + +it('forgets every file a chunk had, not just the winning one', function () { + // A file left behind after a rename or a delete would be adopted by the + // next chunk named the same. + $directory = sys_get_temp_dir() . '/evo_chunks_' . bin2hex(random_bytes(6)); + $store = chunkFileStore($directory); + + try { + $store->write('nav', 'html', 'a'); + $store->write('nav', 'tpl', 'b'); + + expect(array_keys($store->existing('nav')))->toBe(['html', 'tpl']); + + $store->forget('nav'); + + expect($store->existing('nav'))->toBe([]) + ->and($store->resolve('nav', 'column'))->toBe('column'); + } finally { + $store->forget('nav'); + @unlink($directory . '/.htaccess'); + @unlink($directory . '/index.html'); + @rmdir($directory); + } +}); diff --git a/core/tests/Unit/Manager/ChunkFilenameEncodingTest.php b/core/tests/Unit/Manager/ChunkFilenameEncodingTest.php new file mode 100644 index 0000000000..e2b263993b --- /dev/null +++ b/core/tests/Unit/Manager/ChunkFilenameEncodingTest.php @@ -0,0 +1,150 @@ + 'HTML'], EVO_BASE_PATH . 'views/chunks/'); +} + +dataset('chunk names', [ + 'a plain name' => ['nav.main'], + 'mixed case and dashes' => ['Header_2-col'], + 'a space' => ['my Chunk'], + 'a slash' => ['nav/main'], + 'a backslash' => ['back' . chr(92) . 'slash'], + 'a colon' => ['a:b'], + 'a quote' => ['quote"name'], + 'a star' => ['star*name'], + 'a pipe' => ['pipe|name'], + 'a question mark' => ['question?'], + 'angle brackets' => ['less'], + 'a tab' => ["tab\tname"], + 'dot dot' => ['..'], + 'a single dot' => ['.'], + 'a leading dot' => ['.hidden'], + 'a trailing dot' => ['trailing.'], + 'a trailing space' => ['trailing '], + 'a reserved device name' => ['CON'], + 'a lowercase device name' => ['nul'], + 'a numbered device name' => ['COM1'], + 'a percent sign' => ['100%'], + 'something that looks encoded' => ['%2E'], + 'cyrillic' => ['Кнопка'], + 'an em dash' => ['nav—dash'], +]); + +it('round trips every name a chunk has ever had', function (string $name) { + $store = encodingStore(); + $filename = $store->filename($name, 'html'); + + expect($filename)->not->toBeNull() + ->and($store->nameFor($filename, 'html'))->toBe($name); +})->with('chunk names'); + +it('produces a filename every platform accepts', function (string $name) { + $encoded = substr((string) encodingStore()->filename($name, 'html'), 0, -strlen('.html')); + + expect($encoded) + // Windows' forbidden set, and the control range with it. + ->not->toMatch('/[\x00-\x1F\x7F<>:"\/\\\\|?*]/') + // Windows drops these silently, so the file would stop matching the name. + ->not->toEndWith('.') + ->not->toEndWith(' ') + // Hidden on unix, and '.'/'..' are not names at all. + ->not->toStartWith('.') + ->and(in_array(strtoupper($encoded), ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1'], true)) + ->toBeFalse(); +})->with('chunk names'); + +it('leaves a name that was already safe exactly as it is', function () { + // The common case, and the whole reason for encoding rather than hashing: + // a chunk's file is findable by its name in a directory listing. + $store = encodingStore(); + + expect($store->filename('nav.main', 'html'))->toBe('nav.main.html') + ->and($store->filename('Header_2-col', 'html'))->toBe('Header_2-col.html') + ->and($store->filename('Кнопка', 'html'))->toBe('Кнопка.html'); +}); + +it('encodes the escape character first of all', function () { + // Otherwise "100%" and "100%25" would be the same file, and neither would + // decode back to itself. + $store = encodingStore(); + + expect($store->filename('100%', 'html'))->toBe('100%25.html') + ->and($store->filename('%2E', 'html'))->toBe('%252E.html') + ->and($store->nameFor('100%25.html', 'html'))->toBe('100%') + ->and($store->nameFor('%252E.html', 'html'))->toBe('%2E'); +}); + +it('refuses to read a filename it would not have written', function () { + $store = encodingStore(); + + expect($store->nameFor('nav%2.html', 'html'))->toBeNull() + ->and($store->nameFor('nav%zz.html', 'html'))->toBeNull() + // Lower case hex decodes to the same byte, but is not what encode() + // emits - accepting it would give one chunk two files. + ->and($store->nameFor('nav%2fmain.html', 'html'))->toBeNull() + ->and($store->nameFor('nav%2Fmain.html', 'html'))->toBe('nav/main') + ->and($store->nameFor('nav.main.txt', 'html'))->toBeNull() + ->and($store->nameFor('.html', 'html'))->toBeNull(); +}); + +it('refuses a name no encoding can rescue, and says why', function () { + $store = encodingStore(); + + expect($store->refuseReason('nav.main', 'html'))->toBeNull() + ->and($store->refuseReason('', 'html'))->toBe('empty') + ->and($store->refuseReason(' ', 'html'))->toBe('empty') + ->and($store->refuseReason("\xC3\x28", 'html'))->toBe('not_utf8') + // 100 characters is what the column holds, and each escape costs + // three bytes - so a name of stars alone overruns any filesystem. + ->and($store->refuseReason(str_repeat('*', 90), 'html'))->toBe('too_long') + ->and($store->filename(str_repeat('*', 90), 'html'))->toBeNull(); +}); + +it('refuses a name macOS would fold into another one', function () { + // APFS compares NFC and NFD as the same file, so two chunks differing only + // in normal form would share it. Needs ext-intl to detect. + if (!class_exists(Normalizer::class)) { + expect(true)->toBeTrue(); + + return; + } + + $nfd = 'e' . "\xCC\x81" . 'clair'; + $nfc = "\xC3\xA9" . 'clair'; + + expect($nfc)->not->toBe($nfd) + ->and(encodingStore()->refuseReason($nfd, 'html'))->toBe('not_nfc') + ->and(encodingStore()->refuseReason($nfc, 'html'))->toBeNull(); +}); + +it('gives two names that share a file the same collision key', function () { + // Nothing in the encoding can see this: both names are safe, both encode + // to themselves, and both are one file on Windows and on macOS. Whoever + // writes has to compare keys. + $store = encodingStore(); + + expect($store->caseKey('MyChunk', 'html'))->toBe($store->caseKey('mychunk', 'html')) + ->and($store->caseKey('Кнопка', 'html'))->toBe($store->caseKey('КНОПКА', 'html')) + ->and($store->caseKey('nav.main', 'html'))->not->toBe($store->caseKey('nav.other', 'html')) + ->and($store->caseKey("\xC3\x28", 'html'))->toBeNull(); +}); diff --git a/core/tests/Unit/Manager/ChunkFilesWiringTest.php b/core/tests/Unit/Manager/ChunkFilesWiringTest.php new file mode 100644 index 0000000000..6c2aa3a29b --- /dev/null +++ b/core/tests/Unit/Manager/ChunkFilesWiringTest.php @@ -0,0 +1,208 @@ +.html. Not "can live" - lives: + * there is no setting, no column and no migration behind it. The file is the + * chunk if it exists, and the site_htmlsnippets row is what a chunk that has + * not been saved since files existed still holds. + * + * That is what makes the database a deprecation rather than an option: nothing + * in the manager offers it, every save writes a file, and a site crosses over + * one chunk at a time without anybody running anything. + */ + +$read = static fn (string $path): string => str_replace(chr(13), '', (string) file_get_contents($path)); +$core = static fn (string $file): string => $read(dirname(__DIR__, 3) . '/src/' . $file); +$root = static fn (string $file): string => $read(dirname(__DIR__, 4) . '/' . $file); + +test('both readers of a stored chunk go through one rule', function () use ($core) { + // Two things read a stored chunk: Parser::getBaseChunk() on demand, and the + // site cache builder up front - which bakes every chunk into + // Core::$chunkCache and so short circuits the first entirely. A rule in + // only one of them renders a file backed chunk from the stale column on + // every request the cache serves, which is every request. + expect($core('Parser.php')) + ->toContain('ChunkFileStore::make()->resolve((string) $row->name, $row->snippet)') + ->and($core('Legacy/Cache.php')) + ->toContain('$chunkFiles->resolve((string) $doc[\'name\'], $doc[\'snippet\'])') + ->and($core('Support/ChunkFileStore.php')) + ->toContain('public function resolve(string $name, ?string $stored): ?string'); +}); + +test('the file wins and the column is the fallback', function () { + // The whole rule, and the reason nothing needs migrating: a site that + // never saves a chunk keeps rendering exactly what it rendered before. + $resolve = static fn (?string $file, ?string $column): ?string => $file ?? $column; + + expect($resolve('from the file', 'from the column'))->toBe('from the file') + ->and($resolve(null, 'from the column'))->toBe('from the column') + ->and($resolve(null, null))->toBeNull(); +}); + +test('nothing about a chunk is stored to say where its code is', function () use ($core, $root) { + // No column means no migration, and no migration means an update does not + // write into anybody's tree. + foreach (['Support/ChunkFileStore.php', 'Parser.php', 'Legacy/Cache.php', 'Models/SiteHtmlsnippet.php', 'Controllers/Chunk.php'] as $file) { + expect($core($file))->not->toContain('chunksource'); + } + + expect($root('manager/processors/save_htmlsnippet.processor.php'))->not->toContain('chunksource') + ->and($root('manager/views/page/chunk.blade.php'))->not->toContain('chunksource') + ->and(glob(dirname(__DIR__, 3) . '/database/migrations/*chunk*'))->toBe([]) + ->and(glob(dirname(__DIR__, 4) . '/install/stubs/migrations/*chunk*'))->toBe([]); +}); + +test('the manager never offers the database as a place to keep a chunk', function () use ($root) { + // It is a deprecation, not a choice, so it is not in the form and not in + // any language file either. + $view = $root('manager/views/page/chunk.blade.php'); + + expect($view)->not->toContain('name="chunksource"') + ->and($view)->not->toContain('chunk_source'); + + foreach (['en', 'uk', 'ru'] as $lang) { + expect($root("core/lang/$lang/global.php"))->not->toContain('$_lang["chunk_source'); + } +}); + +test('the form still says which file it will write', function () use ($root) { + // The one thing the operator does need: the name, encoded the way the + // store encodes it, so what is on screen is what lands on disk. + $view = $root('manager/views/page/chunk.blade.php'); + + expect($view)->toContain('var encodeChunkName = function(value)') + ->toContain('var RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;') + ->toContain('id="chunk-filename"') + ->toContain('chunk_file_unusable_name'); +}); + +test('every save writes the file, and nothing is saved if it cannot', function () use ($root) { + $processor = $root('manager/processors/save_htmlsnippet.processor.php'); + + expect($processor)->toContain('$chunkStore->write($name, $chunkFormat, $snippet)') + ->toContain('chunkFileWriteFailed($name, $chunkFormat, 77);') + ->toContain('chunkFileWriteFailed($name, $chunkFormat, 78, $id);') + // The column is written too - not as a second source of truth, but so + // that the manager's search, which reads the database, still finds a + // chunk by its contents. + ->toContain("compact('name', 'description','snippet'") + ->not->toContain("unset(\$updates['snippet']);"); +}); + +test('a name that cannot be a file is refused before the save, with a reason', function () use ($root) { + // Four different accidents, four different fixes - a shrug would leave the + // operator guessing which. + $processor = $root('manager/processors/save_htmlsnippet.processor.php'); + + expect($processor)->toContain('function chunkNameRefused(') + ->toContain("'chunk_name_collides'") + ->and($root('core/lang/en/global.php'))->toContain('$_lang["chunk_name_collides"]') + ->and($root('core/lang/uk/global.php'))->toContain('$_lang["chunk_name_collides"]') + ->and($root('core/lang/ru/global.php'))->toContain('$_lang["chunk_name_collides"]'); +}); + +test('a file is never left behind to be adopted by the next chunk', function () use ($root, $core) { + // Renaming or deleting a chunk while its file stays put would hand that + // file - and whatever is in it - to whoever names a chunk the same next. + expect($core('Support/ChunkFileStore.php'))->toContain('public function forget(string $name): void') + ->and($root('manager/processors/save_htmlsnippet.processor.php')) + ->toContain('$chunkStore->forget($renamedFrom);') + ->and($root('manager/processors/delete_htmlsnippet.processor.php')) + ->toContain('ChunkFileStore::make()->forget($name);'); +}); + +test('a duplicate carries the code, not the file', function () use ($root) { + // The original's file is named after the original; the copy writes its own + // the first time it is saved. + expect($root('manager/processors/duplicate_htmlsnippet.processor.php')) + ->toContain('$newHtmlsnippet->snippet = $store->resolve((string) $htmlsnippet->name, $htmlsnippet->snippet);'); +}); + +test('the format list is the only thing a plugin has to touch', function () use ($core, $root) { + // A plugin adds a format and the reader looks for it too - the core needs + // to know no format but html. + expect($root('core/config/view.php'))->toContain("'chunk_formats' => [") + ->and($core('Support/ChunkFileStore.php')) + ->toContain('public function firstExisting(string $name): ?string') + ->and($core('Support/ChunkFileStore.php')) + ->toContain('public function writeExtension(string $name): ?string'); +}); + +test('the database path is marked as the deprecation it is', function () use ($core, $root) { + // `php core/artisan deprecated:list` is how this project keeps track of what + // 3.7 removes, so the column has to be findable there rather than only + // described in prose. + expect($core('Models/SiteHtmlsnippet.php'))->toContain('@deprecated since 3.5.8') + ->and($core('Support/ChunkFileStore.php'))->toContain('@deprecated since 3.5.8') + ->and($core('Parser.php')) + ->toContain("@deprecated since 3.5.8 reading a chunk's code out of the database") + ->and($core('Legacy/Cache.php'))->toContain('@deprecated since 3.5.8') + ->and($core('Controllers/Chunk.php'))->toContain('@deprecated since 3.5.8') + ->and($root('manager/processors/save_htmlsnippet.processor.php')) + ->toContain("@deprecated since 3.5.8 writing a chunk's code to the database"); +}); + +test('every deprecation says when it goes', function () use ($core, $root) { + // The listing groups by removal version; a @deprecated without one is a + // note nobody ever acts on. + foreach (['Models/SiteHtmlsnippet.php', 'Support/ChunkFileStore.php', 'Parser.php', 'Legacy/Cache.php', 'Controllers/Chunk.php'] as $file) { + expect($core($file))->toContain('@todo [remove@3.7]'); + } + + expect($root('manager/processors/save_htmlsnippet.processor.php'))->toContain('@todo [remove@3.7]'); +}); + +test('the form measures filename bytes without a deprecated global', function () use ($root) { + // unescape() has been deprecated for years; TextEncoder is the modern way + // to count UTF-8 bytes, and it counts the same 255 the store measures. + $view = $root('manager/views/page/chunk.blade.php'); + + expect($view)->not->toContain('unescape(') + ->toContain('new TextEncoder().encode(value).length') + ->toContain("byteLength(filename + '.' + selected) <= 255"); +}); + +test('every language the CMS ships has the chunk file strings', function () use ($read) { + // A missing key renders as the lexicon name itself, so a half translated + // set is visible to the operator rather than silently English. + $keys = [ + 'chunk_assigned_file', + 'chunk_file_unusable_name', + 'chunk_file_not_writable', + 'chunk_name_empty', + 'chunk_name_not_utf8', + 'chunk_name_not_nfc', + 'chunk_name_too_long', + 'chunk_name_collides', + ]; + + $missing = []; + foreach (glob(dirname(__DIR__, 3) . '/lang/*/global.php') as $file) { + $source = $read($file); + foreach ($keys as $key) { + if (!str_contains($source, '$_lang["' . $key . '"]')) { + $missing[] = basename(dirname($file)) . '/' . $key; + } + } + } + + expect($missing)->toBe([]); +}); + +test('the two strings that take an argument keep their placeholder', function () use ($read) { + // sprintf() with no %s drops the filename, or the name of the chunk that + // clashes - which is the only useful half of the message. + $missing = []; + foreach (glob(dirname(__DIR__, 3) . '/lang/*/global.php') as $file) { + foreach ($read($file) === '' ? [] : explode(" +", $read($file)) as $line) { + foreach (['chunk_file_not_writable', 'chunk_name_collides'] as $key) { + if (str_starts_with($line, '$_lang["' . $key . '"]') && !str_contains($line, '%s')) { + $missing[] = basename(dirname($file)) . '/' . $key; + } + } + } + } + + expect($missing)->toBe([]); +}); diff --git a/core/tests/Unit/Security/ChunkFileExposureTest.php b/core/tests/Unit/Security/ChunkFileExposureTest.php new file mode 100644 index 0000000000..baa1af5ac6 --- /dev/null +++ b/core/tests/Unit/Security/ChunkFileExposureTest.php @@ -0,0 +1,101 @@ + str_replace( + chr(13), + '', + (string) file_get_contents(dirname(__DIR__, 4) . '/' . $file) +); + +test('the default chunk directory is not under a served path', function () use ($root) { + $config = $root('core/config/view.php'); + + expect($config)->toContain("'chunk_path' => EVO_BASE_PATH . 'views/chunks/',") + ->and($config)->not->toContain("'chunk_path' => EVO_BASE_PATH . 'assets/"); +}); + +test('the directory it lives under ships denied', function () use ($root) { + // views/ carries the same guard core/ does, and has since long before + // this - which is exactly why the chunks went there. + $guard = strtolower($root('views/.htaccess')); + + expect($guard)->toContain('deny from all'); +}); + +test('the root rewrite serves assets straight from disk', function () use ($root) { + // The reason assets/ is not an option: this line hands the request to the + // filesystem before the CMS ever sees it. Read from ht.access - the + // shipped template - because the live .htaccess is not in the repository. + expect($root('ht.access'))->toContain('RewriteRule ^(manager|assets|js|css|images|img)/.*$ - [L]'); +}); + +test('the store writes its own guards, wherever it is pointed', function () { + // The path is configurable, and a plugin or a hoster may point it + // somewhere with no inherited protection at all. + $directory = sys_get_temp_dir() . '/evo_guard_' . bin2hex(random_bytes(6)); + $store = new ChunkFileStore(['html' => 'HTML'], $directory); + + try { + expect($store->ensureDirectory())->toBeTrue() + ->and(is_file($directory . '/.htaccess'))->toBeTrue() + ->and(strtolower((string) file_get_contents($directory . '/.htaccess'))) + ->toContain('deny from all') + ->and(is_file($directory . '/index.html'))->toBeTrue(); + } finally { + @unlink($directory . '/.htaccess'); + @unlink($directory . '/index.html'); + @rmdir($directory); + } +}); + +test('an existing guard is never overwritten', function () { + // Somebody may have tightened it, or replaced it with something their + // server actually reads. + $directory = sys_get_temp_dir() . '/evo_guard_' . bin2hex(random_bytes(6)); + mkdir($directory, 0777, true); + file_put_contents($directory . '/.htaccess', 'Require all denied'); + + $store = new ChunkFileStore(['html' => 'HTML'], $directory); + + try { + expect($store->ensureDirectory())->toBeTrue() + ->and(file_get_contents($directory . '/.htaccess'))->toBe('Require all denied'); + } finally { + @unlink($directory . '/.htaccess'); + @unlink($directory . '/index.html'); + @rmdir($directory); + } +}); + +test('the shipped chunk directory carries its guards', function () { + // So a site gets them from the archive, not from the first chunk anybody + // happens to save. (EVO_BASE_PATH is whatever an earlier test defined it + // as, so the repository root is worked out from this file instead.) + $chunks = dirname(__DIR__, 4) . '/views/chunks/'; + + expect(is_file($chunks . '.htaccess'))->toBeTrue() + ->and(strtolower((string) file_get_contents($chunks . '.htaccess'))) + ->toContain('deny from all') + ->and(is_file($chunks . 'index.html'))->toBeTrue(); +}); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index 3f6eeefb02..af52482868 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1391,6 +1391,7 @@ 'EvolutionCMS\\Support\\ArithmeticExpression' => $baseDir . '/src/Support/ArithmeticExpression.php', 'EvolutionCMS\\Support\\BladeDirective' => $baseDir . '/src/Support/BladeDirective.php', 'EvolutionCMS\\Support\\Captcha' => $baseDir . '/src/Support/Captcha.php', + 'EvolutionCMS\\Support\\ChunkFileStore' => $baseDir . '/src/Support/ChunkFileStore.php', 'EvolutionCMS\\Support\\ContextMenu' => $baseDir . '/src/Support/ContextMenu.php', 'EvolutionCMS\\Support\\DataGrid' => $baseDir . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => $baseDir . '/src/Support/DataSetPager.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 7e5476db4b..d5feb2179f 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2068,6 +2068,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\ArithmeticExpression' => __DIR__ . '/../..' . '/src/Support/ArithmeticExpression.php', 'EvolutionCMS\\Support\\BladeDirective' => __DIR__ . '/../..' . '/src/Support/BladeDirective.php', 'EvolutionCMS\\Support\\Captcha' => __DIR__ . '/../..' . '/src/Support/Captcha.php', + 'EvolutionCMS\\Support\\ChunkFileStore' => __DIR__ . '/../..' . '/src/Support/ChunkFileStore.php', 'EvolutionCMS\\Support\\ContextMenu' => __DIR__ . '/../..' . '/src/Support/ContextMenu.php', 'EvolutionCMS\\Support\\DataGrid' => __DIR__ . '/../..' . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => __DIR__ . '/../..' . '/src/Support/DataSetPager.php', diff --git a/manager/processors/delete_htmlsnippet.processor.php b/manager/processors/delete_htmlsnippet.processor.php index 99d745ce23..1285ade672 100755 --- a/manager/processors/delete_htmlsnippet.processor.php +++ b/manager/processors/delete_htmlsnippet.processor.php @@ -21,7 +21,9 @@ "id" => $id ]); -// delete the chunk. +// delete the chunk. Its file goes with it: left behind, it would be adopted +// silently by the next chunk anybody names the same. +EvolutionCMS\Support\ChunkFileStore::make()->forget($name); EvolutionCMS\Models\SiteHtmlsnippet::destroy($id); // invoke OnChunkFormDelete event diff --git a/manager/processors/duplicate_htmlsnippet.processor.php b/manager/processors/duplicate_htmlsnippet.processor.php index d0cd96bc9e..a9b07166b7 100755 --- a/manager/processors/duplicate_htmlsnippet.processor.php +++ b/manager/processors/duplicate_htmlsnippet.processor.php @@ -21,6 +21,13 @@ // duplicate htmlsnippet $newHtmlsnippet = $htmlsnippet->replicate(); $newHtmlsnippet->name = $htmlsnippet->name.' '.$_lang['duplicated_el_suffix'].$count; + +// The copy gets the code, not the file. The original's file is named after the +// original, so the copy carries the resolved contents in its row and writes its +// own file the first time it is saved. +$store = \EvolutionCMS\Support\ChunkFileStore::make(); +$newHtmlsnippet->snippet = $store->resolve((string) $htmlsnippet->name, $htmlsnippet->snippet); + $newHtmlsnippet->push(); $_SESSION['itemname'] = $newHtmlsnippet->name; diff --git a/manager/processors/save_htmlsnippet.processor.php b/manager/processors/save_htmlsnippet.processor.php index dafcd8d350..30afa7e06d 100755 --- a/manager/processors/save_htmlsnippet.processor.php +++ b/manager/processors/save_htmlsnippet.processor.php @@ -64,6 +64,94 @@ $editor_type = $_POST['which_editor'] != 'none' ? 1 : 2; $editor_name = $_POST['which_editor'] != 'none' ? $_POST['which_editor'] : 'none'; +/** + * Stop rather than lose the edit. + * + * A chunk that keeps its code in a file does not write that code to the column + * as well, so a file that cannot be written means the editor's contents have + * nowhere to go. The form values go back into the session first, which is how + * this processor already handles a rejected save. + */ +function chunkFileWriteFailed($name, $extension, $action, $id = null) +{ + global $_lang; + + evo()->getManagerApi()->saveFormValues($action); + evo()->webAlertAndQuit( + sprintf( + get_by_key($_lang, 'chunk_file_not_writable', 'The chunk file %s could not be written, so nothing was saved. Check the name and the permissions of the chunk directory.'), + (string) $name . '.' . ltrim((string) $extension, '.') + ), + 'index.php?a=' . $action . ($id !== null ? '&id=' . $id : '') + ); +} + +/** + * Stop before a name is asked to be a file it cannot be. + * + * Almost every name can: the hazards are encoded and decoded back, so a chunk + * moved into a file is never renamed. What is left over is three things no + * encoding fixes - and each has a different fix, so each says so. + */ +function chunkNameRefused($name, $extension, $action, $id = null) +{ + global $_lang; + + $store = \EvolutionCMS\Support\ChunkFileStore::make(); + $reason = $store->refuseReason((string) $name, (string) $extension); + + if ($reason === null) { + // The name is fine on its own; the question is whether another chunk + // already answers to the same file. A case-insensitive filesystem - + // which is to say Windows and macOS - cannot keep the two apart. + $others = \EvolutionCMS\Models\SiteHtmlsnippet::query() + ->when($id !== null, fn ($q) => $q->where('id', '!=', $id)) + ->pluck('name') + ->all(); + + $clash = $store->collidingName((string) $name, (string) $extension, $others); + + if ($clash === null) { + return; + } + + $message = sprintf( + get_by_key($_lang, 'chunk_name_collides', 'This name and the chunk "%s" would share one file on Windows and macOS, which tell them apart only by case. Rename one of them.'), + $clash + ); + } else { + $messages = [ + 'empty' => get_by_key($_lang, 'chunk_name_empty', 'A chunk kept in a file needs a name.'), + 'not_utf8' => get_by_key($_lang, 'chunk_name_not_utf8', 'This name is not valid UTF-8, so it cannot become a filename.'), + 'not_nfc' => get_by_key($_lang, 'chunk_name_not_nfc', 'This name uses combining characters. Retype it, or macOS will treat it as the same file as another spelling of the same word.'), + 'too_long' => get_by_key($_lang, 'chunk_name_too_long', 'This name is too long to be a filename once its special characters are escaped.'), + ]; + $message = get_by_key($messages, $reason, 'This name cannot become a filename.'); + } + + evo()->getManagerApi()->saveFormValues($action); + evo()->webAlertAndQuit($message, 'index.php?a=' . $action . ($id !== null ? '&id=' . $id : '')); +} + +/** + * The file this chunk is written to: the format it already uses, or the one the + * form posted, or the default. + * + * A chunk is a file. The row is still written - $snippet goes into it with + * everything else - but only so the manager's search keeps working, because + * search reads the database. Nothing renders from the column once the file is + * there. + * + * @deprecated since 3.5.8 writing a chunk's code to the database + * @todo [remove@3.7] Remove in Evolution CMS 3.7 - drop 'snippet' from both + * writes below once search reads the files. + */ +$chunkStore = \EvolutionCMS\Support\ChunkFileStore::make(); +$posted = get_by_key($_POST, 'chunkfileextension'); +$chunkFormat = $chunkStore->isRegistered($posted) + ? (string) $posted + : (string) $chunkStore->writeExtension($name); + switch ($_POST['mode']) { case '77': @@ -79,6 +167,17 @@ evo()->webAlertAndQuit(sprintf($_lang['duplicate_name_found_general'], $_lang['chunk'], $name), "index.php?a=77"); } + // The file goes first: a row that survived a failed write would claim + // a save that did not happen. A file already sitting at this name + // belongs to whoever put it there and is adopted, not overwritten by a + // chunk that has only just been named. + chunkNameRefused($name, $chunkFormat, 77); + + if ($chunkStore->pathFor($name, $chunkFormat) === null + && !$chunkStore->write($name, $chunkFormat, $snippet)) { + chunkFileWriteFailed($name, $chunkFormat, 77); + } + //do stuff to save the new doc $id = EvolutionCMS\Models\SiteHtmlsnippet::create(compact('name', 'description','snippet','locked','category','editor_type','editor_name','disabled','createdon','editedon'))->getKey(); @@ -120,7 +219,23 @@ //do stuff to save the edited doc $chunk = EvolutionCMS\Models\SiteHtmlsnippet::find($id); - $chunk->update(compact('name', 'description','snippet','locked','category','editor_type','editor_name','disabled','editedon')); + $updates = compact('name', 'description','snippet','locked','category','editor_type','editor_name','disabled','editedon'); + + chunkNameRefused($name, $chunkFormat, 78, $id); + + // Renaming a chunk leaves its old file behind, and the next chunk + // named that would adopt it. The code moves with the name. + $renamedFrom = (string) $chunk->name; + + if (!$chunkStore->write($name, $chunkFormat, $snippet)) { + chunkFileWriteFailed($name, $chunkFormat, 78, $id); + } + + if ($renamedFrom !== '' && $renamedFrom !== $name) { + $chunkStore->forget($renamedFrom); + } + + $chunk->update($updates); // invoke OnChunkFormSave event evo()->invokeEvent("OnChunkFormSave", [ diff --git a/manager/views/page/chunk.blade.php b/manager/views/page/chunk.blade.php index e8d231e926..eec795f363 100644 --- a/manager/views/page/chunk.blade.php +++ b/manager/views/page/chunk.blade.php @@ -50,6 +50,85 @@ function changeRTE() h1help.onclick = function() { document.querySelector('.element-edit-message').classList.toggle('show'); }; + + var extension = document.getElementById('chunkfileextension'), + fileBlock = document.getElementById('assigned-chunk-file'), + filenameLabel = document.getElementById('chunk-filename'), + nameField = document.getElementsByName('name')[0]; + + // The format list can be empty, in which case the block is not + // rendered and there is nothing to keep up to date. + if (!fileBlock || !filenameLabel) { + return; + } + + var directory = fileBlock.dataset.directory || '', + savedName = fileBlock.dataset.name || ''; + + // The same encoding ChunkFileStore::encode() applies, so the name + // shown while typing is the name that will be on disk. Only ASCII + // hazards are listed; everything above them - Cyrillic, dashes - + // is left alone, exactly as on the PHP side. + var RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; + + var hex = function(ch) { + var code = ch.charCodeAt(0).toString(16).toUpperCase(); + + return '%' + (code.length < 2 ? '0' + code : code); + }; + + var encodeChunkName = function(value) { + var out = value.replace(/[%<>:"\/\\|?*\u0000-\u001F\u007F]/g, hex); + + if (out.charAt(0) === '.') { + out = '%2E' + out.slice(1); + } + + if (/[. ]$/.test(out)) { + out = out.slice(0, -1) + hex(out.slice(-1)); + } + + if (RESERVED.test(out)) { + out = hex(out.charAt(0)) + out.slice(1); + } + + return out; + }; + + // What the store refuses outright: an empty name, and one whose + // encoded filename overruns the filesystem. The rest - normal form, + // and a clash with another chunk's file - needs more than this form + // knows, so the save says so instead. + var byteLength = function(value) { + return new TextEncoder().encode(value).length; + }; + + var previewName = function() { + var value = (nameField ? nameField.value : savedName).trim(); + + return value === '' ? '' : encodeChunkName(value); + }; + + var updateFilename = function() { + var filename = previewName(), + selected = extension ? extension.value : '', + // The same 255 the store measures, on the same bytes: the + // whole filename, extension included. + usable = filename !== '' && byteLength(filename + '.' + selected) <= 255; + + filenameLabel.innerText = usable + ? '/' + directory + filename + '.' + selected + : {{ Illuminate\Support\Js::from(ManagerTheme::getLexicon('chunk_file_unusable_name', 'this name cannot become a filename')) }}; + }; + + if (extension) { + extension.addEventListener('change', updateFilename); + } + if (nameField) { + nameField.addEventListener('input', updateFilename); + } + + updateFilename(); }); @@ -158,6 +237,34 @@ function changeRTE() @endif + + @if(!empty($chunkFormats)) +
+ {{ ManagerTheme::getLexicon('chunk_assigned_file', 'Chunk file') }}: + + + @if(count($chunkFormats) > 1) + {{-- Only worth asking once a plugin has put a second + format in the list. --}} +
+ +
+ @else + + @endif +
+ @endif diff --git a/views/chunks/.htaccess b/views/chunks/.htaccess new file mode 100644 index 0000000000..ff2beb844b --- /dev/null +++ b/views/chunks/.htaccess @@ -0,0 +1,2 @@ +order deny,allow +deny from all diff --git a/views/chunks/index.html b/views/chunks/index.html new file mode 100644 index 0000000000..cfb7d0fe18 --- /dev/null +++ b/views/chunks/index.html @@ -0,0 +1,2 @@ +

Unauthorized access

+You're not allowed to access file folder \ No newline at end of file