diff --git a/.gitignore b/.gitignore index 95ad27922d..7b86f4e797 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ Desktop.ini !.gitignore !/.github !/.dockerignore +/tmp/ diff --git a/core/functions/session_proxy.php b/core/functions/session_proxy.php index d8551eefff..2764ef9450 100644 --- a/core/functions/session_proxy.php +++ b/core/functions/session_proxy.php @@ -33,7 +33,9 @@ public static function disabled(): bool } /** - * Drops the session middleware from a front-end group while sessions are disabled. + * Drops the session middleware from a front-end group while sessions are disabled, + * and with it what reads the store: ShareErrorsFromSession would throw + * "Session store not set on request" on every routed front-end request. * @param string[] $middleware * @return string[] */ @@ -46,6 +48,7 @@ public static function filterMiddleware(array $middleware): array return array_values(array_filter($middleware, static fn ($m) => !in_array($m, [ \Illuminate\Session\Middleware\StartSession::class, \EvolutionCMS\Middleware\SessionProxy::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, ], true))); } diff --git a/core/lang/az/global.php b/core/lang/az/global.php index d5a5a951f1..adb4c203e1 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -269,6 +269,7 @@ $_lang["resource_parent"] = 'Ana resurs'; $_lang["resource_parent_help"] = 'Ana resursu təyin etmək üçün ikona klikləyin, sonra Sayt Ağacında bir resurs seçərək yeni ana resurs təyin edin.'; $_lang["resource_permissions_error"] = 'Bu resursu, daxil ola bildiyiniz ən azı bir resurs qrupuna təyin edin.'; +$_lang["resource_save_unconfirmed"] = 'Yadda saxlama təsdiqlənmədi. Redaktor saxlanılmış vəziyyəti göstərmək üçün yenidən yüklənəcək.'; $_lang["resource_setting"] = 'Resurs ayarı'; $_lang["resource_summary"] = 'Qısa məzmun (giriş mətni)'; $_lang["resource_summary_help"] = 'Resurs üçün qısa xülasə daxil edin'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index ec3223eb28..712fd87f52 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -257,6 +257,7 @@ $_lang["resource_parent"] = 'Бацькоўскі рэсурс'; $_lang["resource_parent_help"] = 'Націсніце значок, каб выбраць бацькоўскі рэсурс для гэтага рэсурсу.'; $_lang["resource_permissions_error"] = 'Для гэтага рэсурсу патрэбныя дазволы. Калі ласка, патрабуйце дазволы ад адміністратара сайта.'; +$_lang["resource_save_unconfirmed"] = 'Захаванне не пацверджана. Рэдактар будзе перазагружаны, каб паказаць захаваны стан.'; $_lang["resource_setting"] = 'Налады рэсурсу'; $_lang["resource_summary"] = 'Кароткі змест'; $_lang["resource_summary_help"] = 'Увядзіце кароткае апісанне ці змест гэтага рэсурсу.'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 2f56cee9d6..6684bf6838 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -721,6 +721,7 @@ $_lang["resource_parent"] = 'Родител на Документа'; $_lang["resource_parent_help"] = 'Щракнете на горната икона, за да разрешите (или забраните) избирането на родителя на този документ. След това, щракнете върху документ от дървото, за да укажете неговия нов родителски документ.'; $_lang["resource_permissions_error"] = 'Assign this Resource to at least one Resource Group to which you have access.'; +$_lang["resource_save_unconfirmed"] = 'Записът не беше потвърден. Редакторът ще се презареди, за да покаже съхраненото състояние.'; $_lang["resource_setting"] = 'Настройки на Документа'; $_lang["resource_summary"] = 'Резюме (introtext)'; $_lang["resource_summary_help"] = 'Въведете кратко резюме за документа'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 22c2bd0d52..3301b9f2df 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -725,6 +725,7 @@ $_lang["resource_parent"] = 'Umístění dokumentu'; $_lang["resource_parent_help"] = 'Klikněte v adresářovém stromě na ikonku složky pro jeho otevření (zavření), a potom na dokument v stromu, který chcete nastavit jako umístění dokumentu.'; $_lang["resource_permissions_error"] = 'Přiřaďte tento dokument alespoň do jedné skupiny dokumentů, kterou můžete používat.'; +$_lang["resource_save_unconfirmed"] = 'Uložení se nepodařilo potvrdit. Editor se znovu načte a zobrazí uložený stav.'; $_lang["resource_setting"] = 'Nastavení dokumentu'; $_lang["resource_summary"] = 'Souhrn (introtext)'; $_lang["resource_summary_help"] = 'Model stručného souhrnu dokumentu'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 0178d6e069..2fcb8e7f28 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -722,6 +722,7 @@ $_lang["resource_parent"] = 'Ovenstående ressource'; $_lang["resource_parent_help"] = 'Klik på ikonet for at aktivere funktionen. Klik derefter på den ressource i website træet som denne ressource fremover skal være under.'; $_lang["resource_permissions_error"] = 'Tilføj denne ressource til mindst én ressource gruppe du selv har rettigheder til.'; +$_lang["resource_save_unconfirmed"] = 'Gemningen kunne ikke bekræftes. Editoren genindlæses for at vise den gemte tilstand.'; $_lang["resource_setting"] = 'Ressource redigering'; $_lang["resource_summary"] = 'Introduktion'; $_lang["resource_summary_help"] = 'Indtast et kort resume for denne ressource'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index 829c951a74..1e03a9fd99 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -767,6 +767,7 @@ $_lang["resource_parent"] = 'Container'; $_lang["resource_parent_help"] = 'Klicken Sie auf eine Ressource im Baum, um diese als übergeordnete Ressource zu wählen.'; $_lang["resource_permissions_error"] = 'Weisen Sie diese Ressource mindestens einer Ressourcen-Gruppe zu, zu der Sie Zugriff haben.'; +$_lang["resource_save_unconfirmed"] = 'Das Speichern konnte nicht bestätigt werden. Der Editor wird neu geladen und zeigt den gespeicherten Stand.'; $_lang["resource_setting"] = 'Ressourcen-Eigenschaften'; $_lang["resource_summary"] = 'Zusammenfassung'; $_lang["resource_summary_help"] = 'Geben Sie eine kurze inhaltliche Zusammenfassung der Ressource ein.'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index f0ec7db704..2b7edf1ad5 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -786,6 +786,7 @@ $_lang["resource_parent"] = 'Resource parent'; $_lang["resource_parent_help"] = 'Click the icon to enable setting a Resource parent, then click a Resource in the Site Tree to set a new parent.'; $_lang["resource_permissions_error"] = 'Assign this Resource to at least one Resource Group to which you have access.'; +$_lang["resource_save_unconfirmed"] = 'The save could not be confirmed. The editor reloads to show the stored state.'; $_lang["resource_setting"] = 'Resource setting'; $_lang["resource_summary"] = 'Summary (introtext)'; $_lang["resource_summary_help"] = 'Type a brief summary of the Resource'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index 97c4d0aeeb..925d0fa0c9 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -769,6 +769,7 @@ $_lang["resource_parent"] = 'Padre del documento'; $_lang["resource_parent_help"] = 'Haz clic en el icono de arriba para habilitar (o deshabilitar) seleccionar el padre de este documento. Luego, haz clic en un documento del árbol para seleccionar su nuevo padre.'; $_lang["resource_permissions_error"] = 'Asigna este Documento a por lo menos un Grupo de Documentos al que puedas acceder.'; +$_lang["resource_save_unconfirmed"] = 'No se pudo confirmar el guardado. El editor se recargará para mostrar el estado almacenado.'; $_lang["resource_setting"] = 'Configuración de documento'; $_lang["resource_summary"] = 'Resumen (introtext)'; $_lang["resource_summary_help"] = 'Escribe un resumen corto del documento'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 307241ca0e..6c7669d472 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -722,6 +722,7 @@ $_lang["resource_parent"] = 'سرگروه پرونده'; $_lang["resource_parent_help"] = 'برای انتخاب یا مشخص کردن سرگروه این پرونده روی نماد یا آیکون بالا کلیک کنید سپس از طریق درختی روی پرونده کلیک کنید تا سرگروه جدید آنرا تعیین کنید'; $_lang["resource_permissions_error"] = 'Assign this Resource to at least one Resource Group to which you have access.'; +$_lang["resource_save_unconfirmed"] = 'ذخیره‌سازی تأیید نشد. ویرایشگر برای نمایش وضعیت ذخیره‌شده دوباره بارگذاری می‌شود.'; $_lang["resource_setting"] = 'تنظیمات پرونده'; $_lang["resource_summary"] = 'خلاصه یا مقدمه ی کوتاه مطلب'; $_lang["resource_summary_help"] = 'خلاصه ای مختصر از پرونده را در اینجا ذکر کنید.'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index a8e6de7354..e906a98307 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -721,6 +721,7 @@ $_lang["resource_parent"] = 'Sivun paikka'; $_lang["resource_parent_help"] = 'Napsauta ensin yllä olevaa kansiokuvaketta ja sitten sivukartasta sitä sivua, jonka alle tämä sivu sijoitetaan.'; $_lang["resource_permissions_error"] = 'Sijoita tämä sivu vähintään yhteen sivuryhmään, johon myös itselläsi on käyttöoikeudet.'; +$_lang["resource_save_unconfirmed"] = 'Tallennusta ei voitu vahvistaa. Editori ladataan uudelleen tallennetun tilan näyttämiseksi.'; $_lang["resource_setting"] = 'Sivun asetukset'; $_lang["resource_summary"] = 'Yhteenveto'; $_lang["resource_summary_help"] = 'Sivun yhteenveto.'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index cc0440267e..807f1da0e2 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -724,6 +724,7 @@ $_lang["resource_parent"] = 'Ressource parente'; $_lang["resource_parent_help"] = 'Cliquez sur l\'icône ci-dessus pour activer (ou désactiver) la sélection d\'une Ressource parente. Cliquez ensuite sur une Ressource dans l\'Arbre du Site pour la choisir comme parente.'; $_lang["resource_permissions_error"] = 'Assignez cette Ressource à au moins un Groupe de Ressources auquel vous avez accès.'; +$_lang["resource_save_unconfirmed"] = 'L\'enregistrement n\'a pas pu être confirmé. L\'éditeur se recharge pour afficher l\'état enregistré.'; $_lang["resource_setting"] = 'Propriétés de la Ressource'; $_lang["resource_summary"] = 'Résumé'; $_lang["resource_summary_help"] = 'Rédigez un bref résumé de la Ressource'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index 207bc3675f..2006bb504f 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -722,6 +722,7 @@ $_lang["resource_parent"] = 'אב המסמך'; $_lang["resource_parent_help"] = 'Click on the icon above to enable (or disable) selecting this document\'s parent. Next, click a document in the tree to set its new parent.'; $_lang["resource_permissions_error"] = 'Assign this Document to at least one Document Group which you can access.'; +$_lang["resource_save_unconfirmed"] = 'לא ניתן היה לאשר את השמירה. העורך ייטען מחדש כדי להציג את המצב השמור.'; $_lang["resource_setting"] = 'הגדרות מסמך'; $_lang["resource_summary"] = 'סיכום (introtext)'; $_lang["resource_summary_help"] = 'Type a brief summary of the document'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index d415271379..d2d7c016de 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -769,6 +769,7 @@ $_lang["resource_parent"] = 'Risorsa genitore'; $_lang["resource_parent_help"] = 'Selezionate una Risorsa nella struttura ad albero per impostarla come genitore di questa Risorsa.'; $_lang["resource_permissions_error"] = 'Assegnate questa Risorsa ad almeno un Gruppo Risorse al quale avete accesso.'; +$_lang["resource_save_unconfirmed"] = 'Il salvataggio non è stato confermato. L\'editor verrà ricaricato per mostrare lo stato memorizzato.'; $_lang["resource_setting"] = 'Impostazioni Risorsa'; $_lang["resource_summary"] = 'Sommario'; $_lang["resource_summary_help"] = 'Inserire un breve sommario della Risorsa'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index a94fedc5a3..131476e637 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -797,6 +797,7 @@ $_lang["resource_parent"] = '親リソース'; $_lang["resource_parent_help"] = 'コンテナアイコンをクリックすると、このリソースの親(コンテナ)を変更できる状態になります。アイコンが変化している状態で、親にしたいリソースをリソースツリー上でクリックしてください。もう一度クリックすると元に戻ります。'; $_lang["resource_permissions_error"] = 'このリソースを、少なくともアクセス可能な一つ以上のリソースグループへ割り当ててください。'; +$_lang["resource_save_unconfirmed"] = '保存を確認できませんでした。保存済みの状態を表示するためエディターを再読み込みします。'; $_lang["resource_setting"] = 'リソース設定'; $_lang["resource_summary"] = '要約(序説)'; $_lang["resource_summary_help"] = 'リソースの要約を入力します。リソース変数:[*introtext*]'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index e9df93c16b..4187816b2e 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -757,6 +757,7 @@ $_lang["resource_parent"] = 'Hoofdpagina'; $_lang["resource_parent_help"] = 'Selecteer een Pagina in de Website boomstructuur om het als Hoofdpagina van deze Pagina in te stellen.'; $_lang["resource_permissions_error"] = 'Koppel deze Pagina aan tenminste 1 Paginagroep waar u toegang tot heeft.'; +$_lang["resource_save_unconfirmed"] = 'Het opslaan kon niet worden bevestigd. De editor wordt opnieuw geladen om de opgeslagen staat te tonen.'; $_lang["resource_setting"] = 'Pagina instellingen'; $_lang["resource_summary"] = 'Samenvatting (introductietekst)'; $_lang["resource_summary_help"] = 'Typ een korte beschrijving van de Pagina.'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 14650968e6..0a737347f2 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -721,6 +721,7 @@ $_lang["resource_parent"] = 'Dokumenteier'; $_lang["resource_parent_help"] = 'Klikk på det ovenstående mappeikonet for å sette (eller fjerne) eiervalg. Klikk deretter på et dokument i dokumenttreet for å sette det som eier til dette dokumententet.'; $_lang["resource_permissions_error"] = 'Assign this Resource to at least one Resource Group to which you have access.'; +$_lang["resource_save_unconfirmed"] = 'Lagringen kunne ikke bekreftes. Editoren lastes på nytt for å vise den lagrede tilstanden.'; $_lang["resource_setting"] = 'Dokumentinnstillinger'; $_lang["resource_summary"] = 'Sammendrag'; $_lang["resource_summary_help"] = 'Skriv et kort sammendrag av dokumentet'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index 8561051913..1ead2c86f2 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -770,6 +770,7 @@ $_lang["resource_parent"] = 'Dokument nadrzędny'; $_lang["resource_parent_help"] = 'Kliknij na powyższej ikonie folderu aby włączyć (lub wyłączyć) wybór dokumentu nadrzędnego, a następnie kliknij na dokumencie w drzewie, żeby ustawić go jako nadrzędny dla tego dokumentu.'; $_lang["resource_permissions_error"] = 'Przypisz ten zasób przynajmniej do jednej grupy zasobów do której masz dostęp.'; +$_lang["resource_save_unconfirmed"] = 'Nie udało się potwierdzić zapisu. Edytor zostanie przeładowany, aby pokazać zapisany stan.'; $_lang["resource_setting"] = 'Ustawienia dokumentu'; $_lang["resource_summary"] = 'Wstęp'; $_lang["resource_summary_help"] = 'Wpisz krótki wstęp dla dokumentu'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index 4e209d6889..9530d7f931 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -721,6 +721,7 @@ $_lang["resource_parent"] = 'Document pai'; $_lang["resource_parent_help"] = 'Clique no ícone acima para activar (ou desactivar) a selecção do \'pai\' deste documento. Em seguida clique num documento da árvore para atribuir-lhe parentesco.'; $_lang["resource_permissions_error"] = 'Assign this Resource to at least one Resource Group to which you have access.'; +$_lang["resource_save_unconfirmed"] = 'Não foi possível confirmar a gravação. O editor será recarregado para mostrar o estado guardado.'; $_lang["resource_setting"] = 'Configurações do documento'; $_lang["resource_summary"] = 'Introdução'; $_lang["resource_summary_help"] = 'Escreva um breve resumo do conteúdo do documento.'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 257922584a..75cd85f004 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -788,6 +788,7 @@ $_lang["resource_parent"] = 'Priečinok'; $_lang["resource_parent_help"] = 'Kliknite na ikonu kontajnera hore pre zapnutie (vypnutie) režimu výberu rodičovského zdroja, potom ho vyberte v strome webu vľavo.'; $_lang["resource_permissions_error"] = 'Prepojte tento zdroj aspoň s jednou skupinou zdrojov, ku ktorej máte prístup.'; +$_lang["resource_save_unconfirmed"] = 'Uloženie sa nepodarilo potvrdiť. Editor sa znova načíta a zobrazí uložený stav.'; $_lang["resource_setting"] = 'Nastavenia zdroja'; $_lang["resource_summary"] = 'Anotácia (úvod)'; $_lang["resource_summary_help"] = 'Zadajte krátky popis zdroja'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index d3630b4c38..ee9766dda3 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -722,6 +722,7 @@ $_lang["resource_parent"] = 'Resursförälder'; $_lang["resource_parent_help"] = 'Klicka på ikonen för att aktivera val av förälder för denna resurs. Klicka sedan på en resurs i webbplatsträdet för att ange den som ny förälder.'; $_lang["resource_permissions_error"] = 'Tilldela denna resurs till minst en resursgrupp som du kan komma åt.'; +$_lang["resource_save_unconfirmed"] = 'Sparningen kunde inte bekräftas. Redigeraren laddas om för att visa det sparade läget.'; $_lang["resource_setting"] = 'Resursinställningar'; $_lang["resource_summary"] = 'Sammanfattning'; $_lang["resource_summary_help"] = 'Skriv en kort sammanfattning av resursen'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 922da98a80..a0e5f04d5e 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -793,6 +793,7 @@ $_lang["resource_parent"] = 'Папка'; $_lang["resource_parent_help"] = 'Клацніть мишею на значку контейнера вгорі, щоб включити (виключити) режим вибору батьківського ресурсу, потім виберіть його в дереві сайту зліва.'; $_lang["resource_permissions_error"] = 'Зв\'яжіть цей ресурс принаймні з однією групою ресурсів, до якої у Вас є доступ.'; +$_lang["resource_save_unconfirmed"] = 'Збереження не підтверджено. Редактор перезавантажиться, щоб показати збережений стан.'; $_lang["resource_setting"] = 'Налаштування ресурса'; $_lang["resource_summary"] = 'Анотація (введення)'; $_lang["resource_summary_help"] = 'Введіть короткий опис ресурсу'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 9fb1880cd6..a30d0d7ab8 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -722,6 +722,7 @@ $_lang["resource_parent"] = '文档之父本。'; $_lang["resource_parent_help"] = '点上面文件夹的图标来激活或禁止父本的选择,当激活的时候,你可以从文档树中选择一文档作为本文档的父本。'; $_lang["resource_permissions_error"] = 'Assign this Resource to at least one Resource Group to which you have access.'; +$_lang["resource_save_unconfirmed"] = '无法确认保存。编辑器将重新加载以显示已保存的状态。'; $_lang["resource_setting"] = '文档设置'; $_lang["resource_summary"] = '摘要 (介绍)'; $_lang["resource_summary_help"] = '输入文档的大纲'; diff --git a/core/src/Core.php b/core/src/Core.php index 24a2f0e5f1..e569307ac4 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -401,20 +401,21 @@ public function checkAuth($context = '') */ public function checkAccess($userId): bool { - if (empty($context)) { - $context = $this->getContext(); - } - $user = User::query()->find(evo()->getLoginUserID($context)); - if (is_null($user)) { + // one query for the three block columns; a user without an attributes row passes as before + $row = User::query() + ->leftJoin('user_attributes', 'user_attributes.internalKey', '=', 'users.id') + ->where('users.id', (int) $userId) + ->first(['users.id', 'user_attributes.blocked', 'user_attributes.blockeduntil', 'user_attributes.blockedafter']); + if (is_null($row)) { return false; } - if ($user->attributes->blocked != 0) { + if ((int) $row->blocked != 0) { return false; } - if ($user->attributes->blockeduntil > time()) { + if ((int) $row->blockeduntil > time()) { return false; } - if ($user->attributes->blockedafter < time() && $user->attributes->blockedafter > 0) { + if ((int) $row->blockedafter < time() && (int) $row->blockedafter > 0) { return false; } return true; @@ -1318,14 +1319,14 @@ protected function runPubStatusSync($timeNow) * Otherwise runs at shutdown, so it works on every SAPI including Apache mod_php, plain CGI and Windows/IIS * @param callable $callback */ - protected function deferAfterResponse(callable $callback) + public function deferAfterResponse(callable $callback) { register_shutdown_function(function () use ($callback) { $this->finishRequest(); try { $callback(); } catch (\Throwable $e) { - Log::error('Deferred publish-status sync failed: ' . $e->getMessage()); + Log::error('Deferred after-response work failed: ' . $e->getMessage(), ['exception' => $e]); } }); } @@ -3944,9 +3945,9 @@ public function lockElement($type, $id) if (!$type || !$id || !$userId) { return false; } - return ActiveUserLock::query()->updateOrCreate(['elementId' => $id], - ['internalKey' => $userId, 'elementType' => $type, 'lasthit' => $this->time, 'sid' => $this->sid]); + Services\ManagerActivity::instance()->lock($type, $id, (int) $userId, (string) $this->sid, (int) $this->time); + return true; } /** @@ -4001,19 +4002,8 @@ public function updateValidatedUserSession() // web users are stored with negative keys $userId = $this->getLoginUserType() == 'manager' ? $this->getLoginUserID() : -$this->getLoginUserID(); if ($userId != false) { - Models\ActiveUserSession::where('internalKey', $userId)->delete(); - Models\ActiveUserSession::where('sid', $this->sid)->delete(); - try { - Models\ActiveUserSession::updateOrCreate([ - 'internalKey' => $userId, - 'sid' => $this->sid, - ], [ - 'lasthit' => $this->time, - 'ip' => $_SESSION['ip'], - ]); - } catch (\Exception $exception) { - - } + Services\ManagerActivity::instance() + ->touchSession((int) $userId, (string) $this->sid, (int) $this->time, (string) $_SESSION['ip']); } } @@ -4703,7 +4693,8 @@ public function clearCache($type = '', $report = false) } elseif ($type === 'document') { $sync = new Legacy\Cache(); $sync->setCachepath($cache_dir); - $sync->refreshDocumentCache($this); + // a manager request gets its answer first, the file rebuild follows once the response is out + $sync->refreshDocumentCache($this, $this->isBackend() && !is_cli()); } elseif (preg_match('@^[1-9]\d*$@', $type)) { $key = ($this->getConfig('cache_type') == 2) ? $this->makePageCacheKey($type) : $type; $file_name = "docid_" . $key . "_*.pageCache.php"; diff --git a/core/src/Legacy/Cache.php b/core/src/Legacy/Cache.php index 007fc48863..65a562b304 100644 --- a/core/src/Legacy/Cache.php +++ b/core/src/Legacy/Cache.php @@ -153,13 +153,20 @@ public function emptyCache($evo = null) * @param null|Interfaces\CoreInterface $evo * @since 3.5.8 */ - public function refreshDocumentCache($evo = null) + /** + * @param bool $deferRebuild rebuild siteCache.idx.php after the response instead of inside it; the purge + * itself stays synchronous so no stale page is served meanwhile + */ + public function refreshDocumentCache($evo = null, bool $deferRebuild = false) { $evo = $this->resolveCore($evo); \Illuminate\Support\Facades\Cache::flush(); $this->clearPageCache(); - $this->buildCache($evo); - $this->publishTimeConfig(); + $rebuild = function () use ($evo) { + $this->buildCache($evo); + $this->publishTimeConfig(); + }; + $deferRebuild ? $evo->deferAfterResponse($rebuild) : $rebuild(); } /** diff --git a/core/src/ManagerTheme.php b/core/src/ManagerTheme.php index 87e095602b..ec759f4048 100644 --- a/core/src/ManagerTheme.php +++ b/core/src/ManagerTheme.php @@ -8,7 +8,6 @@ use EvolutionCMS\Exceptions\ServiceValidationException; use EvolutionCMS\Interfaces\ManagerThemeInterface; use EvolutionCMS\Interfaces\CoreInterface; -use EvolutionCMS\Models\ActiveUser; use EvolutionCMS\Models\UserAttribute; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -483,6 +482,9 @@ public function handleRoute() $response = $evo->router->dispatch($request); $response->send(); + + // Bookkeeping writes go out after the client has its page; Tracy still has its bar to append, so it stays attached. + Services\ManagerActivity::instance()->flush(!\Tracy\Debugger::isEnabled()); } public function handle($action, array $data = []) @@ -518,8 +520,11 @@ public function handle($action, array $data = []) /********************************************************************/ // log action, unless it's a frame request if ($action > 0 && \in_array($action, [1, 2, 7], true) === false) { - $log = new Legacy\LogHandler; - $log->initAndWriteLog(); + // $_SESSION['itemname'] is dropped below, so it is captured for the deferred write + $itemName = (string) ($_SESSION['itemname'] ?? ''); + Services\ManagerActivity::instance()->defer(static function () use ($itemName): void { + (new Legacy\LogHandler)->initAndWriteLog('', '', '', '', '', $itemName); + }); } /********************************************************************/ @@ -820,15 +825,14 @@ public function saveAction($action) $this->getCore()->getManagerApi()->action = $action; if ((int)$action > 1) { - ActiveUser::where('internalKey', $this->getCore()->getLoginUserID('mgr'))->forceDelete(); - $activeUser = new ActiveUser; - $activeUser->sid = session_id(); - $activeUser->internalKey = (int)$this->getCore()->getLoginUserID('mgr'); - $activeUser->username = $_SESSION['mgrShortname']; - $activeUser->lasthit = (int)$this->getCore()->tstart; - $activeUser->action = (int)$action; - $activeUser->id = (int)$this->getItemId(); - $activeUser->save(); + Services\ManagerActivity::instance()->setAction( + (int)$this->getCore()->getLoginUserID('mgr'), + (string)session_id(), + (string)$_SESSION['mgrShortname'], + (int)$this->getCore()->tstart, + (int)$action, + (int)$this->getItemId() + ); $flag = true; } diff --git a/core/src/Services/DocumentSave/DocumentSaveContext.php b/core/src/Services/DocumentSave/DocumentSaveContext.php new file mode 100644 index 0000000000..f778de6bf9 --- /dev/null +++ b/core/src/Services/DocumentSave/DocumentSaveContext.php @@ -0,0 +1,132 @@ +getLoginUserID('mgr'); + + return new self( + userId: $userId, + role: (int) ($_SESSION['mgrRole'] ?? 0), + managerDocgroups: array_map('intval', (array) ($_SESSION['mgrDocgroups'] ?? [])), + now: $evo->timestamp((int) get_by_key($_SERVER, 'REQUEST_TIME', 0)), + config: fn (string $key) => $evo->getConfig($key), + permission: fn (string $name) => (bool) $evo->hasPermission($name), + event: function (string $name, array $params) use ($evo) { + $evo->invokeEvent($name, $params); + }, + stripAlias: fn (string $alias) => (string) $evo->stripAlias($alias), + toTimestamp: fn (string $date) => (int) $evo->toTimeStamp($date), + parentIds: fn (int $id) => (array) $evo->getParentIds($id), + canCreateIn: fn (int $parent) => Permissions::canCreateIn($parent), + userGroupsLoader: fn () => array_values(array_unique(array_map('intval', MemberGroup::query() + ->join('membergroup_access', 'membergroup_access.membergroup', '=', 'member_groups.user_group') + ->where('member_groups.member', $userId) + ->pluck('documentgroup')->all()))), + lang: fn (string $key) => (string) __('global.' . $key), + ); + } + + public function isAdministrator(): bool + { + return $this->role === 1; + } + + public function config(string $key): mixed + { + return ($this->config)($key); + } + + public function can(string $permission): bool + { + return ($this->permission)($permission); + } + + public function fire(string $event, array $params): void + { + ($this->event)($event, $params); + } + + public function stripAlias(string $alias): string + { + return ($this->stripAlias)($alias); + } + + public function toTimestamp(string $date): int + { + return ($this->toTimestamp)($date); + } + + /** + * @return int[] + */ + public function parentIds(int $id): array + { + return ($this->parentIds)($id); + } + + public function canCreateIn(int $parent): bool + { + return ($this->canCreateIn)($parent); + } + + /** + * Document groups the user belongs to, queried once per save. + * + * @return int[] + */ + public function userGroups(): array + { + return $this->userGroups ??= ($this->userGroupsLoader)(); + } + + public function lang(string $key): string + { + return ($this->lang)($key); + } +} diff --git a/core/src/Services/DocumentSave/DocumentSaveDenied.php b/core/src/Services/DocumentSave/DocumentSaveDenied.php new file mode 100644 index 0000000000..20dc6973a2 --- /dev/null +++ b/core/src/Services/DocumentSave/DocumentSaveDenied.php @@ -0,0 +1,20 @@ +mode === 'new'; + } +} diff --git a/core/src/Services/DocumentSaveService.php b/core/src/Services/DocumentSaveService.php new file mode 100644 index 0000000000..c4696ae2c9 --- /dev/null +++ b/core/src/Services/DocumentSaveService.php @@ -0,0 +1,299 @@ +ctx; + + $id = is_numeric($input['id'] ?? null) ? (int) $input['id'] : 0; + $mode = ($id > 0 || in_array((string) ($input['mode'] ?? ''), ['73', '27'], true)) ? 'edit' : 'new'; + + $type = (string) ($input['type'] ?? 'document'); + $pagetitle = (string) ($input['pagetitle'] ?? ''); + if (trim($pagetitle) === '') { + $pagetitle = $ctx->lang($type === 'reference' ? 'untitled_weblink' : 'untitled_resource'); + } + $parent = (int) get_by_key($input, 'parent', 0, 'is_scalar'); + $template = (int) ($input['template'] ?? 0); + $makePublic = ($input['chkalldocs'] ?? '') === 'on'; + $groupPairs = $makePublic ? [] : get_by_key($input, 'docgroups', [], 'is_array'); + $usePermissions = (int) $ctx->config('use_udperms') === 1; + + $existing = null; + if ($mode === 'edit') { + $existing = SiteContent::withTrashed()->find($id); + if ($existing === null) { + throw new DocumentSaveDenied($ctx->lang('error_no_results'), false); + } + } + + $alias = $this->resolveAlias((string) ($input['alias'] ?? ''), $pagetitle, $id, $parent); + + // a non administrator may not take their own groups off the document + if (!$ctx->isAdministrator() + && DocumentGroupSync::locksOut(DocumentGroupSync::postedGroups($groupPairs), $ctx->userGroups())) { + throw new DocumentSaveDenied($ctx->lang('resource_permissions_error')); + } + + if ($usePermissions && ($existing === null || (int) $existing->parent !== $parent) && !$ctx->canCreateIn($parent)) { + throw new DocumentSaveDenied($ctx->lang('access_permission_parent_denied')); + } + + $tvs = TemplateVariableValues::forTemplate($template, $id, !$ctx->isAdministrator(), $ctx->managerDocgroups); + $tvValues = TemplateVariableInput::values($tvs, $input); + + $now = $ctx->now; + $pubDate = empty($input['pub_date']) ? 0 : $ctx->toTimestamp((string) $input['pub_date']); + $unpubDate = empty($input['unpub_date']) ? 0 : $ctx->toTimestamp((string) $input['unpub_date']); + $published = PublishState::fromDates((int) ($input['published'] ?? 0), $pubDate, $unpubDate, $now); + $mayPublish = $ctx->can('publish_document'); + + $fields = [ + 'introtext' => $input['introtext'] ?? '', + 'content' => $input['ta'] ?? '', + 'pagetitle' => $pagetitle, + 'longtitle' => $input['longtitle'] ?? '', + 'type' => $type, + 'description' => $input['description'] ?? '', + 'alias' => $alias, + 'link_attributes' => $input['link_attributes'] ?? '', + 'isfolder' => (int) ($input['isfolder'] ?? 0), + 'richtext' => (int) ($input['richtext'] ?? 0), + 'parent' => $parent, + 'template' => $template, + 'menuindex' => (int) ($input['menuindex'] ?? 0), + 'searchable' => (int) ($input['searchable'] ?? 0), + 'cacheable' => (int) ($input['cacheable'] ?? 0), + 'editedby' => $ctx->userId, + 'editedon' => $now, + 'contentType' => $input['contentType'] ?? 'text/html', + 'content_dispo' => (int) ($input['content_dispo'] ?? 0), + 'hide_from_tree' => (int) ($input['hide_from_tree'] ?? 0), + 'menutitle' => $input['menutitle'] ?? '', + 'hidemenu' => (int) ($input['hidemenu'] ?? 0), + 'alias_visible' => (int) ($input['alias_visible'] ?? 0), + ]; + + // find() hides trashed rows, so a missing parent is a trashed one + $parentRow = $parent > 0 ? SiteContent::withTrashed()->select('id', 'isfolder', 'deleted')->find($parent) : null; + if ($parent > 0 && ($parentRow === null || (int) $parentRow->deleted === 1)) { + $fields['deleted'] = 1; + } + + if ($mode === 'new') { + $fields['createdby'] = $ctx->userId; + $fields += PublishState::forNew($published, $pubDate, $unpubDate, $now, $ctx->userId, $mayPublish); + + // only the event sees this id: the row gets auto increment, as it always did (id is not fillable) + $ctx->fire('OnBeforeDocFormSave', ['mode' => 'new', 'id' => $this->announcedId()]); + + $editedon = 0; + $id = $this->transaction(function () use (&$editedon, $fields, $tvs, $tvValues, $parent, $parentRow, $groupPairs, $usePermissions) { + $document = SiteContent::withTrashed()->create($fields); + $id = (int) $document->getKey(); + $editedon = (int) $document->editedon; + TemplateVariableValues::sync($id, $tvs, $tvValues); + if ($usePermissions) { + $this->attachGroupsToNew($id, $parent, $groupPairs); + } + $this->markAsFolder($parentRow); + + return $id; + }); + + $ctx->fire('OnDocFormSave', ['mode' => 'new', 'id' => $id]); + } else { + $this->guardEdit($existing, $parent, $published, $pubDate, $unpubDate); + + $oldParent = (int) $existing->parent; + if (SiteContent::withTrashed()->where('parent', $id)->exists()) { + $fields['isfolder'] = 1; + } + $fields += PublishState::forEdit($published, $pubDate, $unpubDate, $now, $ctx->userId, $mayPublish, $existing->getAttributes()); + + $ctx->fire('OnBeforeDocFormSave', ['mode' => 'upd', 'id' => $id]); + + $editedon = 0; + $this->transaction(function () use (&$editedon, $existing, $id, $fields, $tvs, $tvValues, $parent, $oldParent, $parentRow, $groupPairs, $makePublic, $usePermissions) { + foreach ($fields as $field => $value) { + $existing->{$field} = $value; + } + $existing->save(); + $editedon = (int) $existing->editedon; + TemplateVariableValues::sync($id, $tvs, $tvValues); + if ($usePermissions && ($this->ctx->can('manage_groups') || $this->ctx->can('manage_document_permissions'))) { + $kept = DocumentGroupSync::forExistingDocument($id, $groupPairs, $this->ctx->userGroups(), $this->ctx->can('manage_groups'), $makePublic); + if (!$kept) { + throw new DocumentSaveDenied($this->ctx->lang('resource_permissions_error')); + } + } + $this->markAsFolder($parentRow); + if ($oldParent !== $parent && $oldParent > 0 + && !SiteContent::withTrashed()->where('parent', $oldParent)->exists()) { + SiteContent::withTrashed()->where('id', $oldParent)->update(['isfolder' => 0]); + } + }); + + $ctx->fire('OnDocFormSave', ['mode' => 'upd', 'id' => $id]); + } + + // after the event, a plugin may have changed the groups + DocumentPrivacy::refresh($id); + + return new DocumentSaveResult($id, $mode, $type, $parent, $pagetitle, $alias, $editedon); + } + + /** + * Rules that only apply to an existing document. + */ + private function guardEdit(SiteContent $existing, int $parent, int $published, int $pubDate, int $unpubDate): void + { + $ctx = $this->ctx; + $id = (int) $existing->getKey(); + + if ($id === (int) $ctx->config('site_start')) { + if ($published === 0) { + throw new DocumentSaveDenied('Document is linked to site_start variable and cannot be unpublished!', false); + } + if ($pubDate > $ctx->now || $unpubDate !== 0) { + throw new DocumentSaveDenied('Document is linked to site_start variable and cannot have publish or unpublish dates set!', false); + } + } + if ($parent === $id) { + throw new DocumentSaveDenied("Document can not be it's own parent!", false); + } + if (in_array($id, array_map('intval', $ctx->parentIds($parent)), true)) { + throw new DocumentSaveDenied("Document descendant can not be it's parent!", false); + } + } + + /** + * The alias to store: generated, stripped and checked for duplicates as the settings ask. + */ + private function resolveAlias(string $alias, string $pagetitle, int $id, int $parent): string + { + $ctx = $this->ctx; + + if (!$ctx->config('friendly_urls')) { + return $alias === '' ? '' : $ctx->stripAlias($alias); + } + + $allowDuplicates = (bool) $ctx->config('allow_duplicate_alias'); + + if ($alias === '') { + if (!$ctx->config('automatic_alias')) { + return ''; + } + $alias = strtolower($ctx->stripAlias(trim($pagetitle))); + + // a duplicate gets a counter; without allow_duplicate_alias the whole site counts + $base = $alias; + $count = 1; + while ($this->aliasQuery($alias, $id, $allowDuplicates ? $parent : null)->exists()) { + $alias = $base . $count++; + } + + return $alias; + } + + $alias = $ctx->stripAlias($alias); + // with duplicates allowed, or alias paths on, only the same level has to be unique + $sameLevelOnly = $allowDuplicates || $ctx->config('use_alias_path'); + $duplicate = $this->aliasQuery($alias, $id, $sameLevelOnly ? $parent : null)->first(); + if ($duplicate !== null) { + throw new DocumentSaveDenied(sprintf($ctx->lang('duplicate_alias_found'), $duplicate->id, $alias)); + } + + return $alias; + } + + private function aliasQuery(string $alias, int $id, ?int $parent) + { + return SiteContent::withTrashed()->select('id') + ->where('id', '<>', $id) + ->where('alias', $alias) + ->when($parent !== null, fn ($q) => $q->where('parent', $parent)); + } + + /** + * The id docid_incrmnt_method promises to OnBeforeDocFormSave: first gap, max + 1, or '' for auto increment. + */ + private function announcedId(): int|string + { + switch ((string) $this->ctx->config('docid_incrmnt_method')) { + case '1': + $table = SiteContent::query()->getQuery()->getGrammar()->wrapTable('site_content'); + $id = SiteContent::withTrashed() + ->leftJoin('site_content as t1', function ($join) use ($table) { + $join->on(DB::raw($table . '.id + 1'), '=', 't1.id'); + }) + ->whereNull('t1.id')->min('site_content.id'); + + return (int) $id + 1; + case '2': + return (int) SiteContent::withTrashed()->max('id') + 1; + default: + return ''; + } + } + + private function attachGroupsToNew(int $id, int $parent, array $groupPairs): void + { + $ctx = $this->ctx; + $groupsOfParent = $parent > 0 + ? array_map('intval', DocumentGroup::query()->where('document', $parent)->pluck('document_group')->all()) + : []; + $manageGroups = $ctx->can('manage_groups'); + $manageDocPerms = $ctx->can('manage_document_permissions'); + $userGroups = ($manageGroups || $manageDocPerms) ? $ctx->userGroups() : []; + + DocumentGroupSync::forNewDocument($id, $groupPairs, $groupsOfParent, $userGroups, $manageGroups, $manageDocPerms); + } + + private function markAsFolder(?SiteContent $parentRow): void + { + if ($parentRow !== null && (int) $parentRow->isfolder !== 1) { + SiteContent::withTrashed()->where('id', $parentRow->getKey())->update(['isfolder' => 1]); + } + } + + private function transaction(callable $callback): mixed + { + return SiteContent::resolveConnection()->transaction($callback); + } +} diff --git a/core/src/Services/ManagerActivity.php b/core/src/Services/ManagerActivity.php new file mode 100644 index 0000000000..5466d8b425 --- /dev/null +++ b/core/src/Services/ManagerActivity.php @@ -0,0 +1,145 @@ + */ + private array $jobs = []; + + private bool $flushed = false; + + private bool $shutdownRegistered = false; + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public static function reset(): void + { + self::$instance = null; + } + + /** + * Queues a write; the queue is flushed after the response or, when the request exits early, at shutdown. + */ + public function defer(callable $job): void + { + $this->jobs[] = $job; + $this->flushed = false; + + if (!$this->shutdownRegistered) { + $this->shutdownRegistered = true; + register_shutdown_function(function (): void { + $this->flush(); + }); + } + } + + /** + * Replaces the active_user_sessions row of the user: one row per user, keyed by the current session id. + */ + public function touchSession(int $userId, string $sid, int $time, string $ip): void + { + $this->defer(static function () use ($userId, $sid, $time, $ip): void { + ActiveUserSession::query() + ->where('internalKey', $userId) + ->orWhere('sid', $sid) + ->delete(); + ActiveUserSession::query()->insert([ + 'internalKey' => $userId, + 'sid' => $sid, + 'lasthit' => $time, + 'ip' => $ip, + ]); + }); + } + + /** + * Records which manager action (and item) the user is on: one row per user in active_users. + */ + public function setAction(int $userId, string $sid, string $username, int $time, int $action, ?int $itemId): void + { + $this->defer(static function () use ($userId, $sid, $username, $time, $action, $itemId): void { + ActiveUser::query()->where('internalKey', $userId)->delete(); + ActiveUser::query()->insert([ + 'sid' => $sid, + 'internalKey' => $userId, + 'username' => $username, + 'lasthit' => $time, + 'action' => (string) $action, + 'id' => $itemId, + ]); + }); + } + + /** + * Takes or refreshes the element lock, same row selection as before: the lock is keyed by element id. + */ + public function lock(int $type, int $id, int $userId, string $sid, int $time): void + { + $this->defer(static function () use ($type, $id, $userId, $sid, $time): void { + ActiveUserLock::query()->updateOrCreate( + ['elementId' => $id], + ['internalKey' => $userId, 'elementType' => $type, 'lasthit' => $time, 'sid' => $sid] + ); + }); + } + + public function hasPending(): bool + { + return $this->jobs !== []; + } + + /** + * Runs the queued writes in one transaction. With $detach the response is flushed to the client first + * (FPM / LiteSpeed), so the writes cost the request nothing; other SAPIs just run them at the end. + * Idempotent: the second call is a no-op unless something was queued in between. + */ + public function flush(bool $detach = false): void + { + if ($this->flushed || $this->jobs === []) { + return; + } + $this->flushed = true; + $jobs = $this->jobs; + $this->jobs = []; + + if ($detach) { + evo()->finishRequest(); + } + + try { + DB::transaction(static function () use ($jobs): void { + foreach ($jobs as $job) { + $job(); + } + }); + } catch (\Throwable $e) { + if (!$detach) { + throw $e; + } + // the client is gone already, the log is all that is left to tell + Log::error('Manager activity write failed: ' . $e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/core/src/Support/DocumentPrivacy.php b/core/src/Support/DocumentPrivacy.php new file mode 100644 index 0000000000..254effb8ed --- /dev/null +++ b/core/src/Support/DocumentPrivacy.php @@ -0,0 +1,65 @@ +join('membergroup_access', 'document_groups.document_group', '=', 'membergroup_access.documentgroup') + ->where('document_groups.document', $documentId) + ->distinct() + ->pluck('membergroup_access.context') + ->map(fn ($context) => (int) $context) + ->all(); + + $flags = []; + if ($context !== self::MANAGER) { + $flags['privateweb'] = in_array(self::WEB, $contexts, true) ? 1 : 0; + } + if ($context !== self::WEB) { + $flags['privatemgr'] = in_array(self::MANAGER, $contexts, true) ? 1 : 0; + } + + SiteContent::withTrashed()->where('id', $documentId)->update($flags); + } + + /** + * Recomputes one flag for every document, after the access groups screen changed the links. + */ + public static function refreshAll(int $context): void + { + $field = $context === self::WEB ? 'privateweb' : 'privatemgr'; + + $ids = DocumentGroup::query() + ->join('membergroup_access', function ($join) use ($context) { + $join->on('document_groups.document_group', '=', 'membergroup_access.documentgroup') + ->where('membergroup_access.context', '=', $context); + }) + ->distinct() + ->pluck('document_groups.document') + ->map(fn ($id) => (int) $id) + ->all(); + + SiteContent::withTrashed()->where($field, 1) + ->when($ids, fn ($q) => $q->whereNotIn('id', $ids)) + ->update([$field => 0]); + if ($ids) { + SiteContent::withTrashed()->whereIn('id', $ids)->where($field, 0)->update([$field => 1]); + } + } +} diff --git a/core/src/Support/DocumentSave/DocumentGroupSync.php b/core/src/Support/DocumentSave/DocumentGroupSync.php new file mode 100644 index 0000000000..e3645e0212 --- /dev/null +++ b/core/src/Support/DocumentSave/DocumentGroupSync.php @@ -0,0 +1,144 @@ + $manageGroups || in_array($group, $userGroups); + + $wanted = []; + foreach ($pairs as $pair) { + if (!is_scalar($pair)) { + continue; + } + [$group, $link] = array_pad(explode(',', (string) $pair, 2), 2, 'new'); + if ($canTouch((int) $group)) { + $wanted[(int) $group] = $link; + } + } + + $current = []; + $untouchable = []; + foreach (DocumentGroup::query()->where('document', $documentId)->get(['id', 'document_group']) as $row) { + if ($canTouch($row->document_group)) { + $current[$row->document_group] = $row->id; + } else { + $untouchable[] = $row->document_group; + } + } + + $insert = []; + foreach ($wanted as $group => $link) { + if (isset($current[$group])) { + unset($current[$group]); + } elseif ($link === 'new') { + $insert[] = $group; + } + } + + if (!$manageGroups && $userGroups !== []) { + $remaining = array_merge($untouchable, array_diff(array_keys($wanted), array_keys($current)), $insert); + if (array_intersect($userGroups, $remaining) === []) { + return false; + } + } + + self::insert($documentId, $insert); + if ($current) { + DocumentGroup::query()->whereIn('id', array_values($current))->delete(); + } + if ($makePublic) { + DocumentGroup::query()->where('document', $documentId)->delete(); + } + + return true; + } + + /** + * @param int[] $groups + */ + private static function insert(int $documentId, array $groups): void + { + $rows = []; + foreach (array_unique($groups) as $group) { + $rows[] = ['document_group' => (int) $group, 'document' => $documentId]; + } + if ($rows) { + DocumentGroup::query()->insert($rows); + } + } +} diff --git a/core/src/Support/DocumentSave/PublishState.php b/core/src/Support/DocumentSave/PublishState.php new file mode 100644 index 0000000000..a76795f406 --- /dev/null +++ b/core/src/Support/DocumentSave/PublishState.php @@ -0,0 +1,89 @@ + 0 && $pubDate < $now) { + $published = 1; + } elseif ($pubDate > $now) { + $published = 0; + } + if ($unpubDate > 0 && $unpubDate < $now) { + $published = 0; + } + + return $published; + } + + /** + * @return array{published:int, pub_date:int, unpub_date:int, publishedon:int, publishedby:int} + */ + public static function forNew(int $published, int $pubDate, int $unpubDate, int $now, int $userId, bool $mayPublish): array + { + $published = self::fromDates($published, $pubDate, $unpubDate, $now); + if (!$mayPublish) { + $published = $pubDate = $unpubDate = 0; + } + + return [ + 'published' => $published, + 'pub_date' => $pubDate, + 'unpub_date' => $unpubDate, + 'publishedon' => $published ? ($pubDate ?: $now) : 0, + 'publishedby' => $published ? $userId : 0, + ]; + } + + /** + * @param array $existing the stored row: published, pub_date, unpub_date, publishedon, publishedby + * @return array{published:int, pub_date:int, unpub_date:int, publishedon:int, publishedby:int} + */ + public static function forEdit(int $published, int $pubDate, int $unpubDate, int $now, int $userId, bool $mayPublish, array $existing): array + { + // without publish_document the publish state stays as it was + if (!$mayPublish) { + return [ + 'published' => (int) $existing['published'], + 'pub_date' => (int) $existing['pub_date'], + 'unpub_date' => (int) $existing['unpub_date'], + 'publishedon' => (int) $existing['publishedon'], + 'publishedby' => (int) $existing['publishedby'], + ]; + } + + $published = self::fromDates($published, $pubDate, $unpubDate, $now); + $wasPublished = (int) $existing['published']; + + if (!$wasPublished && $published) { + $publishedon = $now; + $publishedby = $userId; + } elseif ($pubDate > 0 && $pubDate <= $now && $published) { + $publishedon = $pubDate; + $publishedby = $userId; + } elseif ($wasPublished && !$published) { + $publishedon = 0; + $publishedby = 0; + } else { + $publishedon = (int) $existing['publishedon']; + $publishedby = (int) $existing['publishedby']; + } + + return [ + 'published' => $published, + 'pub_date' => $pubDate, + 'unpub_date' => $unpubDate, + 'publishedon' => $publishedon, + 'publishedby' => $publishedby, + ]; + } +} diff --git a/core/src/Support/DocumentSave/SaveResponse.php b/core/src/Support/DocumentSave/SaveResponse.php new file mode 100644 index 0000000000..9ae21aedf8 --- /dev/null +++ b/core/src/Support/DocumentSave/SaveResponse.php @@ -0,0 +1,55 @@ +id; + if (!$saved->isNew() && $refreshPreview) { + return $siteUrl . "index.php?id=$id&z=manprev"; + } + + if ($stay !== '') { + $newAction = $saved->type === 'reference' ? '72' : '4'; + $url = $stay === '2' + ? "index.php?a=27&id=$id&r=1&stay=2" + : "index.php?a=$newAction&pid={$saved->parent}&r=1&stay=" . (int) $stay; + } else { + $url = "index.php?a=3&id=$id&r=1"; + } + + return $saved->isNew() ? $url : $url . $listingPath; + } + + /** + * @return array + */ + public static function payload(DocumentSaveResult $saved, string $redirect, string $token = ''): array + { + return [ + 'success' => true, + 'id' => $saved->id, + 'mode' => $saved->mode, + 'type' => $saved->type, + 'parent' => $saved->parent, + 'pagetitle' => $saved->pagetitle, + 'alias' => $saved->alias, + 'editedon' => $saved->editedon, + 'redirect' => $redirect, + 'token' => $token, + ]; + } +} diff --git a/core/src/Support/DocumentSave/TemplateVariableInput.php b/core/src/Support/DocumentSave/TemplateVariableInput.php new file mode 100644 index 0000000000..9352280442 --- /dev/null +++ b/core/src/Support/DocumentSave/TemplateVariableInput.php @@ -0,0 +1,62 @@ + + */ + public static function values(array $tvs, array $input): array + { + $values = []; + foreach ($tvs as $tv) { + $values[(int) $tv['id']] = self::value($tv, $input); + } + + return $values; + } +} diff --git a/core/src/Support/DocumentSave/TemplateVariableValues.php b/core/src/Support/DocumentSave/TemplateVariableValues.php new file mode 100644 index 0000000000..e72c3bb0b2 --- /dev/null +++ b/core/src/Support/DocumentSave/TemplateVariableValues.php @@ -0,0 +1,89 @@ + + */ + public static function forTemplate(int $template, int $documentId, bool $restrictToGroups, array $managerGroups): array + { + $query = SiteTmplvar::query()->distinct() + ->select('site_tmplvars.id', 'site_tmplvars.name', 'site_tmplvars.type', 'site_tmplvars.default_text', + 'site_tmplvar_contentvalues.id as value_id', 'site_tmplvar_contentvalues.value') + ->join('site_tmplvar_templates', 'site_tmplvar_templates.tmplvarid', '=', 'site_tmplvars.id') + ->leftJoin('site_tmplvar_contentvalues', function ($join) use ($documentId) { + $join->on('site_tmplvar_contentvalues.tmplvarid', '=', 'site_tmplvars.id') + ->where('site_tmplvar_contentvalues.contentid', '=', $documentId); + }) + ->leftJoin('site_tmplvar_access', 'site_tmplvar_access.tmplvarid', '=', 'site_tmplvars.id') + ->where('site_tmplvar_templates.templateid', $template) + ->orderBy('site_tmplvars.rank'); + + if ($restrictToGroups) { + $query->leftJoin('document_groups', 'site_tmplvar_contentvalues.contentid', '=', 'document_groups.document') + ->where(function ($q) use ($managerGroups) { + $q->whereNull('site_tmplvar_access.documentgroup') + ->orWhereIn('document_groups.document_group', $managerGroups); + }); + } + + $rows = []; + foreach ($query->get() as $row) { + $rows[] = [ + 'id' => (int) $row->id, + 'name' => (string) $row->name, + 'type' => (string) $row->type, + 'default_text' => (string) $row->default_text, + 'value_id' => $row->value_id === null ? null : (int) $row->value_id, + 'value' => $row->value, + ]; + } + + return $rows; + } + + /** + * One insert, one delete and an update per changed value; untouched rows cost nothing. + * + * @param array $tvs rows from forTemplate() + * @param array $desired TV id => value, null removes the row + */ + public static function sync(int $documentId, array $tvs, array $desired): void + { + $insert = []; + $delete = []; + foreach ($tvs as $tv) { + $value = $desired[$tv['id']] ?? null; + if ($value === null) { + if ($tv['value_id'] !== null) { + $delete[] = $tv['id']; + } + } elseif ($tv['value_id'] === null) { + $insert[] = ['tmplvarid' => $tv['id'], 'contentid' => $documentId, 'value' => $value]; + } elseif ((string) $tv['value'] !== $value) { + SiteTmplvarContentvalue::query() + ->where('contentid', $documentId)->where('tmplvarid', $tv['id']) + ->update(['value' => $value]); + } + } + + if ($insert) { + SiteTmplvarContentvalue::query()->insert($insert); + } + if ($delete) { + SiteTmplvarContentvalue::query()->where('contentid', $documentId)->whereIn('tmplvarid', $delete)->delete(); + } + } +} diff --git a/core/tests/Mocks/no_session_worker.php b/core/tests/Mocks/no_session_worker.php index a7264a7b51..46ed620667 100644 --- a/core/tests/Mocks/no_session_worker.php +++ b/core/tests/Mocks/no_session_worker.php @@ -44,5 +44,6 @@ public function save(): void { $this->calls[] = 'save'; } Illuminate\Session\Middleware\StartSession::class, EvolutionCMS\Middleware\SessionProxy::class, Illuminate\Routing\Middleware\SubstituteBindings::class, + Illuminate\View\Middleware\ShareErrorsFromSession::class, ]), ]); diff --git a/core/tests/Support/DocumentSaveDatabase.php b/core/tests/Support/DocumentSaveDatabase.php new file mode 100644 index 0000000000..dc8007ee7f --- /dev/null +++ b/core/tests/Support/DocumentSaveDatabase.php @@ -0,0 +1,169 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + // model events, so the closure table hooks of SiteContent run as they do in the manager + $capsule->setEventDispatcher(new Dispatcher($capsule->getContainer())); + $capsule->bootEloquent(); + + $container = $capsule->getContainer(); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($container); + Model::setConnectionResolver($capsule->getDatabaseManager()); + // a model boots once per process; re-boot so its listeners land on this dispatcher + Model::clearBootedModels(); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + + $schema->create('site_content', function (Blueprint $table) { + $table->increments('id'); + $table->string('type')->default('document'); + $table->string('contentType')->default('text/html'); + $table->string('pagetitle')->default(''); + $table->string('longtitle')->default(''); + $table->string('description')->default(''); + $table->string('alias')->default(''); + $table->string('link_attributes')->default(''); + $table->integer('published')->default(0); + $table->integer('pub_date')->default(0); + $table->integer('unpub_date')->default(0); + $table->integer('parent')->default(0); + $table->integer('isfolder')->default(0); + $table->text('introtext')->nullable(); + $table->text('content')->nullable(); + $table->integer('richtext')->default(1); + $table->integer('template')->default(0); + $table->integer('menuindex')->default(0); + $table->integer('searchable')->default(1); + $table->integer('cacheable')->default(1); + $table->integer('createdby')->default(0); + $table->integer('createdon')->default(0); + $table->integer('editedby')->default(0); + $table->integer('editedon')->default(0); + $table->integer('deleted')->default(0); + $table->integer('deletedon')->default(0); + $table->integer('deletedby')->default(0); + $table->integer('publishedon')->default(0); + $table->integer('publishedby')->default(0); + $table->string('menutitle')->default(''); + $table->integer('hide_from_tree')->default(0); + $table->integer('privateweb')->default(0); + $table->integer('privatemgr')->default(0); + $table->integer('content_dispo')->default(0); + $table->integer('hidemenu')->default(0); + $table->integer('alias_visible')->default(1); + }); + $schema->create('site_content_closure', function (Blueprint $table) { + $table->increments('closure_id'); + $table->integer('ancestor'); + $table->integer('descendant'); + $table->integer('depth'); + }); + $schema->create('site_tmplvars', function (Blueprint $table) { + $table->increments('id'); + $table->string('type')->default('text'); + $table->string('name')->default(''); + $table->text('default_text')->nullable(); + $table->integer('rank')->default(0); + }); + $schema->create('site_tmplvar_templates', function (Blueprint $table) { + $table->integer('tmplvarid'); + $table->integer('templateid'); + }); + $schema->create('site_tmplvar_contentvalues', function (Blueprint $table) { + $table->increments('id'); + $table->integer('tmplvarid'); + $table->integer('contentid'); + $table->text('value')->nullable(); + $table->unique(['tmplvarid', 'contentid']); + }); + $schema->create('site_tmplvar_access', function (Blueprint $table) { + $table->increments('id'); + $table->integer('tmplvarid'); + $table->integer('documentgroup'); + }); + $schema->create('document_groups', function (Blueprint $table) { + $table->increments('id'); + $table->integer('document_group'); + $table->integer('document'); + }); + $schema->create('membergroup_access', function (Blueprint $table) { + $table->increments('id'); + $table->integer('membergroup'); + $table->integer('documentgroup'); + $table->integer('context')->default(0); + }); + $schema->create('member_groups', function (Blueprint $table) { + $table->increments('id'); + $table->integer('user_group'); + $table->integer('member'); + }); + + return $capsule; + } + + /** + * @param array $config settings the save reads, e.g. use_udperms, friendly_urls + * @param string[] $permissions granted manager permissions + * @param int[] $userGroups document groups the user is a member of + * @param array $events collects every fired event as [name, params, snapshot] + * @param callable|null $snapshot called on every event, its result is stored next to the event + */ + public static function context( + array $config = [], + array $permissions = [], + int $role = 1, + array $userGroups = [], + array &$events = [], + ?callable $snapshot = null, + int $now = 1_700_000_000, + ?callable $canCreateIn = null, + ): DocumentSaveContext { + return new DocumentSaveContext( + userId: 7, + role: $role, + managerDocgroups: $userGroups, + now: $now, + config: fn (string $key) => $config[$key] ?? null, + permission: fn (string $name) => in_array($name, $permissions, true), + event: function (string $name, array $params) use (&$events, $snapshot) { + $events[] = [$name, $params, $snapshot ? $snapshot() : null]; + }, + stripAlias: fn (string $alias) => preg_replace('/[^a-z0-9-]+/', '-', strtolower($alias)), + toTimestamp: fn (string $date) => (int) strtotime($date), + // like Core::getParentIds(): the ancestors of $id, not $id itself + parentIds: function (int $id) { + $ids = []; + while ($id > 0) { + $id = (int) Capsule::table('site_content')->where('id', $id)->value('parent'); + if ($id > 0) { + $ids[] = $id; + } + } + return $ids; + }, + canCreateIn: $canCreateIn ?? fn (int $parent) => true, + userGroupsLoader: fn () => $userGroups, + lang: fn (string $key) => $key === 'duplicate_alias_found' ? 'duplicate %s %s' : $key, + ); + } +} diff --git a/core/tests/Unit/CoreCheckAccessTest.php b/core/tests/Unit/CoreCheckAccessTest.php new file mode 100644 index 0000000000..abc83006a1 --- /dev/null +++ b/core/tests/Unit/CoreCheckAccessTest.php @@ -0,0 +1,68 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('username')->default(''); + }); + $schema->create('user_attributes', function (Blueprint $table) { + $table->increments('id'); + $table->integer('internalKey')->default(0); + $table->integer('blocked')->default(0); + $table->integer('blockeduntil')->default(0); + $table->integer('blockedafter')->default(0); + }); + foreach ([1, 2, 3, 4, 5] as $id) { + Capsule::table('users')->insert(['id' => $id, 'username' => "user$id"]); + } + foreach ([ + ['internalKey' => 1], + ['internalKey' => 2, 'blocked' => 1], + ['internalKey' => 3, 'blockeduntil' => time() + 86400], + ['internalKey' => 4, 'blockedafter' => time() - 86400], + ] as $row) { + Capsule::table('user_attributes')->insert($row); + } + + $this->core = (new ReflectionClass(Core::class))->newInstanceWithoutConstructor(); +}); + +afterEach(function () { + Capsule::connection()->disconnect(); + \Illuminate\Container\Container::setInstance(null); +}); + +test('checkAccess admits a clear user and a user without an attributes row', function () { + expect($this->core->checkAccess(1))->toBeTrue() + ->and($this->core->checkAccess(5))->toBeTrue(); +}); + +test('checkAccess refuses blocked, temporarily blocked and expired users, and unknown ids', function () { + expect($this->core->checkAccess(2))->toBeFalse() + ->and($this->core->checkAccess(3))->toBeFalse() + ->and($this->core->checkAccess(4))->toBeFalse() + ->and($this->core->checkAccess(99))->toBeFalse(); +}); + +test('checkAccess is a single query', function () { + Capsule::connection()->enableQueryLog(); + $this->core->checkAccess(1); + + expect(Capsule::connection()->getQueryLog())->toHaveCount(1); +}); diff --git a/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php b/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php index 8e77a77a7b..4ade7d2af7 100644 --- a/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php +++ b/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php @@ -16,8 +16,10 @@ $processorPath = dirname(__DIR__, 4) . '/manager/processors/save_content.processor.php'; $processor = file_get_contents($processorPath); + $response = file_get_contents(dirname(__DIR__, 4) . '/core/src/Support/DocumentSave/SaveResponse.php'); + expect($processor)->toContain('$newResourceAction = ($type == "reference") ? "72" : "4";'); - expect($processor)->toContain('if ($type == "reference") {'); + expect($response)->toContain("\$newAction = \$saved->type === 'reference' ? '72' : '4';"); expect($processor)->not->toContain('if ($_POST[\'mode\'] == "72")'); expect($processor)->not->toContain('if ($_POST[\'mode\'] == "4")'); }); diff --git a/core/tests/Unit/NoSessionModeTest.php b/core/tests/Unit/NoSessionModeTest.php index 8f7ccc4fb6..fff1b65508 100644 --- a/core/tests/Unit/NoSessionModeTest.php +++ b/core/tests/Unit/NoSessionModeTest.php @@ -3,6 +3,7 @@ use EvolutionCMS\Middleware\SessionProxy; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; +use Illuminate\View\Middleware\ShareErrorsFromSession; // Constants are process-global, so every combination boots in its own PHP process. function noSessionRun(string $noSession, string $manager): array @@ -29,7 +30,7 @@ function noSessionRun(string $noSession, string $manager): array expect($r['disabled'])->toBeFalse() ->and($r['store'])->toBe(['start', 'put', 'save']) - ->and($r['middleware'])->toBe([StartSession::class, SessionProxy::class, SubstituteBindings::class]); + ->and($r['middleware'])->toBe([StartSession::class, SessionProxy::class, SubstituteBindings::class, ShareErrorsFromSession::class]); }); test('sessions stay on when NO_SESSION is absent or false', function () { diff --git a/core/tests/Unit/Services/DocumentSaveServiceTest.php b/core/tests/Unit/Services/DocumentSaveServiceTest.php new file mode 100644 index 0000000000..3d54360d1b --- /dev/null +++ b/core/tests/Unit/Services/DocumentSaveServiceTest.php @@ -0,0 +1,299 @@ + Facade::clearResolvedInstances()); + +const DS_NOW = 1_700_000_000; + +/** + * 1 folder (group 3, published) + * └ 2 page (group 3, TV 1 = "old", TV 2 = "keep") + * 4 other folder + * Template 1 carries TVs 1 and 2; document group 3 is linked to a web user group. + */ +function bootSaveFixture(): Capsule +{ + $capsule = DocumentSaveDatabase::boot(); + foreach ([ + ['id' => 1, 'pagetitle' => 'folder', 'alias' => 'folder', 'isfolder' => 1, 'published' => 1, 'privateweb' => 1], + ['id' => 2, 'pagetitle' => 'page', 'alias' => 'page', 'parent' => 1, 'template' => 1, 'published' => 1, 'publishedon' => 500, 'publishedby' => 4, 'privateweb' => 1], + ['id' => 4, 'pagetitle' => 'other', 'alias' => 'other', 'isfolder' => 1], + ] as $row) { + Capsule::table('site_content')->insert($row); + } + Capsule::table('site_content_closure')->insert([ + ['ancestor' => 1, 'descendant' => 1, 'depth' => 0], + ['ancestor' => 2, 'descendant' => 2, 'depth' => 0], + ['ancestor' => 1, 'descendant' => 2, 'depth' => 1], + ['ancestor' => 4, 'descendant' => 4, 'depth' => 0], + ]); + Capsule::table('site_tmplvars')->insert([ + ['id' => 1, 'name' => 'one', 'type' => 'text', 'default_text' => '', 'rank' => 1], + ['id' => 2, 'name' => 'two', 'type' => 'text', 'default_text' => '', 'rank' => 2], + ]); + Capsule::table('site_tmplvar_templates')->insert([['tmplvarid' => 1, 'templateid' => 1], ['tmplvarid' => 2, 'templateid' => 1]]); + Capsule::table('site_tmplvar_contentvalues')->insert([ + ['tmplvarid' => 1, 'contentid' => 2, 'value' => 'old'], + ['tmplvarid' => 2, 'contentid' => 2, 'value' => 'keep'], + ]); + Capsule::table('document_groups')->insert([ + ['document_group' => 3, 'document' => 1], + ['document_group' => 3, 'document' => 2], + ]); + Capsule::table('membergroup_access')->insert([['membergroup' => 1, 'documentgroup' => 3, 'context' => 1]]); + + return $capsule; +} + +function saveForm(array $overrides = []): array +{ + return $overrides + [ + 'id' => '', 'mode' => '4', 'pagetitle' => 'New page', 'alias' => '', 'type' => 'document', + 'parent' => '1', 'template' => '1', 'published' => '1', 'pub_date' => '', 'unpub_date' => '', + 'ta' => 'body', 'introtext' => '', 'longtitle' => '', 'description' => '', 'link_attributes' => '', + 'isfolder' => '0', 'richtext' => '1', 'menuindex' => '0', 'searchable' => '1', 'cacheable' => '1', + 'contentType' => 'text/html', 'content_dispo' => '0', 'hide_from_tree' => '0', 'menutitle' => '', + 'hidemenu' => '0', 'alias_visible' => '1', 'syncsite' => '1', + ]; +} + +function dbSnapshot(): array +{ + return [ + 'level' => Capsule::connection()->transactionLevel(), + 'titles' => Capsule::table('site_content')->orderBy('id')->pluck('pagetitle')->all(), + ]; +} + +test('a new document is created with its tvs, inherited groups, folder flag and privacy in one go', function () { + bootSaveFixture(); + $events = []; + $service = new DocumentSaveService(DocumentSaveDatabase::context( + config: ['use_udperms' => 1, 'friendly_urls' => 1, 'automatic_alias' => 1], + permissions: ['publish_document'], + role: 2, + userGroups: [3], + events: $events, + snapshot: 'dbSnapshot', + )); + Capsule::table('site_content')->where('id', 4)->update(['isfolder' => 0]); + + $result = $service->save(saveForm(['parent' => '4', 'tv1' => 'first', 'tv2' => '', 'docgroups' => []])); + + $row = Capsule::table('site_content')->find($result->id); + expect($result->isNew())->toBeTrue() + ->and($result->alias)->toBe('new-page') + ->and($row->pagetitle)->toBe('New page') + ->and((int) $row->published)->toBe(1) + ->and((int) $row->publishedby)->toBe(7) + ->and((int) $row->createdby)->toBe(7) + // createdon is not fillable; the creating hook of the model writes it, for the legacy processor too + ->and((int) $row->createdon)->toBeGreaterThan(0) + ->and($result->editedon)->toBe((int) $row->editedon) + ->and($result->editedon)->toBeGreaterThan(0) + ->and(Capsule::table('site_tmplvar_contentvalues')->where('contentid', $result->id)->pluck('value', 'tmplvarid')->all())->toBe([1 => 'first']) + ->and(Capsule::table('site_content')->where('id', 4)->value('isfolder'))->toBe(1) + ->and(Capsule::table('site_content_closure')->where('descendant', $result->id)->count())->toBe(2); + + // events: before fires with no row and outside a transaction, after fires once the row is committed + expect(array_column($events, 0))->toBe(['OnBeforeDocFormSave', 'OnDocFormSave']) + ->and($events[0][1])->toBe(['mode' => 'new', 'id' => '']) + ->and($events[0][2])->toBe(['level' => 0, 'titles' => ['folder', 'page', 'other']]) + ->and($events[1][1])->toBe(['mode' => 'new', 'id' => $result->id]) + ->and($events[1][2])->toBe(['level' => 0, 'titles' => ['folder', 'page', 'other', 'New page']]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('OnBeforeDocFormSave still announces the id docid_incrmnt_method promises', function () { + bootSaveFixture(); + $events = []; + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['docid_incrmnt_method' => 2], events: $events)); + $service->save(saveForm()); + + $gaps = []; + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['docid_incrmnt_method' => 1], events: $gaps)); + $service->save(saveForm()); + + // max + 1 with ids 1, 2, 4 is 5; the first gap is 3; the row itself is auto increment either way + expect($events[0][1])->toBe(['mode' => 'new', 'id' => 5]) + ->and($gaps[0][1])->toBe(['mode' => 'new', 'id' => 3]) + ->and($events[1][1]['id'])->toBe(5) + ->and($gaps[1][1]['id'])->toBe(6); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a new document under a private parent inherits its groups and becomes private', function () { + bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['use_udperms' => 1], permissions: ['publish_document'], role: 2, userGroups: [3])); + + $result = $service->save(saveForm(['parent' => '1'])); + + expect(Capsule::table('document_groups')->where('document', $result->id)->pluck('document_group')->all())->toBe([3]) + ->and((int) Capsule::table('site_content')->where('id', $result->id)->value('privateweb'))->toBe(1); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('without publish_document a new document is stored unpublished', function () { + bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(role: 2)); + + $result = $service->save(saveForm(['published' => '1', 'pub_date' => '2020-01-01'])); + + expect(Capsule::table('site_content')->find($result->id))->toMatchObject(['published' => 0, 'pub_date' => 0, 'publishedon' => 0]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('editing writes the tv diff, moves the document and fixes both folder flags', function () { + $capsule = bootSaveFixture(); + $events = []; + $service = new DocumentSaveService(DocumentSaveDatabase::context( + config: ['use_udperms' => 1], + permissions: ['publish_document', 'manage_groups'], + events: $events, + snapshot: 'dbSnapshot', + )); + + $capsule->getConnection()->enableQueryLog(); + $result = $service->save(saveForm([ + 'id' => '2', 'mode' => '27', 'pagetitle' => 'moved', 'alias' => 'moved', 'parent' => '4', + 'tv1' => 'new', 'tv2' => 'keep', 'docgroups' => ['3,1'], + ])); + $queries = count($capsule->getConnection()->getQueryLog()); + + $row = Capsule::table('site_content')->find(2); + expect($result->isNew())->toBeFalse() + ->and($row->pagetitle)->toBe('moved') + ->and((int) $row->parent)->toBe(4) + ->and((int) $row->publishedon)->toBe(500) + ->and(Capsule::table('site_tmplvar_contentvalues')->where('contentid', 2)->pluck('value', 'tmplvarid')->all())->toBe([1 => 'new', 2 => 'keep']) + ->and(Capsule::table('document_groups')->where('document', 2)->pluck('document_group')->all())->toBe([3]) + // the old parent lost its last child, the new one gained one + ->and((int) Capsule::table('site_content')->where('id', 1)->value('isfolder'))->toBe(0) + ->and((int) Capsule::table('site_content')->where('id', 4)->value('isfolder'))->toBe(1) + ->and(Capsule::table('site_content_closure')->where('descendant', 2)->where('ancestor', 4)->exists())->toBeTrue() + ->and(array_column($events, 0))->toBe(['OnBeforeDocFormSave', 'OnDocFormSave']) + ->and($events[1][1])->toBe(['mode' => 'upd', 'id' => 2]) + ->and($events[1][2]['level'])->toBe(0) + // the legacy processor needed ~30 queries for this form + ->and($queries)->toBeLessThanOrEqual(20); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('an unchanged edit does not touch the tv table', function () { + $capsule = bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(permissions: ['publish_document'])); + + $capsule->getConnection()->enableQueryLog(); + $service->save(saveForm(['id' => '2', 'mode' => '27', 'pagetitle' => 'page', 'alias' => 'page', 'tv1' => 'old', 'tv2' => 'keep'])); + + $tvWrites = array_filter($capsule->getConnection()->getQueryLog(), fn ($q) => preg_match('/^(insert|update|delete)\b.*site_tmplvar_contentvalues/', $q['query'])); + expect($tvWrites)->toBe([]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('removing the last own group rolls the whole save back and fires no after event', function () { + bootSaveFixture(); + $events = []; + $service = new DocumentSaveService(DocumentSaveDatabase::context( + config: ['use_udperms' => 1], + permissions: ['publish_document', 'manage_document_permissions'], + role: 2, + userGroups: [3], + events: $events, + )); + + // chkalldocs: the group list is empty, which passes the early check but leaves the user with nothing + $save = fn () => $service->save(saveForm(['id' => '2', 'mode' => '27', 'pagetitle' => 'changed', 'alias' => 'page', 'chkalldocs' => 'on', 'tv1' => 'changed'])); + + expect($save)->toThrow(DocumentSaveDenied::class, 'resource_permissions_error') + ->and(Capsule::table('site_content')->where('id', 2)->value('pagetitle'))->toBe('page') + ->and(Capsule::table('site_tmplvar_contentvalues')->where('contentid', 2)->where('tmplvarid', 1)->value('value'))->toBe('old') + ->and(Capsule::table('document_groups')->where('document', 2)->count())->toBe(1) + ->and(array_column($events, 0))->toBe(['OnBeforeDocFormSave']) + ->and(Capsule::connection()->transactionLevel())->toBe(0); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a duplicate alias is refused with the id of the other document', function () { + bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['friendly_urls' => 1])); + + try { + $service->save(saveForm(['alias' => 'other'])); + $this->fail('expected a denial'); + } catch (DocumentSaveDenied $denied) { + expect($denied->getMessage())->toBe('duplicate 4 other') + ->and($denied->restoreForm)->toBeTrue(); + } +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('an automatic alias gets a counter while it collides', function () { + bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['friendly_urls' => 1, 'automatic_alias' => 1])); + + expect($service->save(saveForm(['pagetitle' => 'Other']))->alias)->toBe('other1') + ->and($service->save(saveForm(['pagetitle' => 'Other']))->alias)->toBe('other2'); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('the parent permission is checked for new documents and on a move only', function () { + bootSaveFixture(); + $asked = []; + $context = DocumentSaveDatabase::context( + config: ['use_udperms' => 1], + permissions: ['publish_document'], + canCreateIn: function (int $parent) use (&$asked) { + $asked[] = $parent; + return $parent !== 4; + }, + ); + $service = new DocumentSaveService($context); + + // same parent: not asked + $service->save(saveForm(['id' => '2', 'mode' => '27', 'alias' => 'page', 'parent' => '1'])); + expect($asked)->toBe([]); + + $save = fn () => $service->save(saveForm(['id' => '2', 'mode' => '27', 'alias' => 'page', 'parent' => '4'])); + expect($save)->toThrow(DocumentSaveDenied::class, 'access_permission_parent_denied') + ->and($asked)->toBe([4]) + ->and((int) Capsule::table('site_content')->where('id', 2)->value('parent'))->toBe(1); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a non administrator cannot post a group list without one of their own groups', function () { + bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['use_udperms' => 1], role: 2, userGroups: [3])); + + $save = fn () => $service->save(saveForm(['docgroups' => ['9,new']])); + + expect($save)->toThrow(DocumentSaveDenied::class, 'resource_permissions_error') + ->and(Capsule::table('site_content')->count())->toBe(3); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('structural guards refuse a missing document, a self parent and a descendant parent', function () { + bootSaveFixture(); + $service = new DocumentSaveService(DocumentSaveDatabase::context(config: ['site_start' => 1], permissions: ['publish_document'])); + + $denied = function (array $form) use ($service): DocumentSaveDenied { + try { + $service->save(saveForm($form)); + } catch (DocumentSaveDenied $e) { + return $e; + } + $this->fail('expected a denial'); + }; + + expect($denied(['id' => '99', 'mode' => '27'])->getMessage())->toBe('error_no_results') + ->and($denied(['id' => '99', 'mode' => '27'])->restoreForm)->toBeFalse() + ->and($denied(['id' => '2', 'mode' => '27', 'parent' => '2'])->getMessage())->toContain('own parent') + ->and($denied(['id' => '1', 'mode' => '27', 'parent' => '2'])->getMessage())->toContain('descendant') + ->and($denied(['id' => '1', 'mode' => '27', 'parent' => '0', 'published' => '0'])->getMessage())->toContain('cannot be unpublished') + ->and($denied(['id' => '1', 'mode' => '27', 'parent' => '0', 'unpub_date' => '2030-01-01'])->getMessage())->toContain('unpublish dates'); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('the save processor delegates to the service and keeps the redirect and cache logic', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/processors/save_content.processor.php'); + + expect($source)->toContain('DocumentSaveService::forManager()->save($_POST)') + ->and($source)->toContain('catch (\EvolutionCMS\Services\DocumentSave\DocumentSaveDenied $denied)') + ->and($source)->toContain("hasPermission('save_document')") + ->and($source)->toContain("clearCache('document')") + ->and($source)->not->toContain('invokeEvent') + ->and($source)->not->toContain('SiteTmplvarContentvalue'); +}); diff --git a/core/tests/Unit/Services/ManagerActivityTest.php b/core/tests/Unit/Services/ManagerActivityTest.php new file mode 100644 index 0000000000..ea659b7671 --- /dev/null +++ b/core/tests/Unit/Services/ManagerActivityTest.php @@ -0,0 +1,148 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $container = $capsule->getContainer(); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($container); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('active_user_sessions', function (Blueprint $table) { + $table->string('sid', 128)->primary(); + $table->integer('internalKey')->default(0); + $table->integer('lasthit')->default(0); + $table->string('ip')->default(''); + }); + $schema->create('active_users', function (Blueprint $table) { + $table->string('sid', 128)->primary(); + $table->integer('internalKey')->default(0); + $table->string('username', 50)->default(''); + $table->integer('lasthit')->default(0); + $table->string('action', 10)->default(''); + $table->integer('id')->nullable(); + }); + $schema->create('active_user_locks', function (Blueprint $table) { + $table->increments('id'); + $table->string('sid', 128)->default(''); + $table->integer('internalKey')->default(0); + $table->integer('elementType')->default(0); + $table->integer('elementId')->default(0); + $table->integer('lasthit')->default(0); + }); + + return $capsule; +} + +beforeEach(function () { + ManagerActivity::reset(); + $this->capsule = bootActivityDatabase(); +}); + +afterEach(function () { + ManagerActivity::instance()->flush(); + ManagerActivity::reset(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + Capsule::connection()->disconnect(); + \Illuminate\Container\Container::setInstance(null); +}); + +test('nothing is written until flush', function () { + $activity = ManagerActivity::instance(); + $activity->touchSession(7, 'sid-a', 1000, '10.0.0.1'); + $activity->setAction(7, 'sid-a', 'admin', 1000, 27, 42); + $activity->lock(7, 42, 7, 'sid-a', 1000); + + expect($activity->hasPending())->toBeTrue() + ->and(Capsule::table('active_user_sessions')->count())->toBe(0) + ->and(Capsule::table('active_users')->count())->toBe(0) + ->and(Capsule::table('active_user_locks')->count())->toBe(0); + + $activity->flush(); + + expect($activity->hasPending())->toBeFalse() + ->and(Capsule::table('active_user_sessions')->get()->map(fn ($r) => (array) $r)->all()) + ->toBe([['sid' => 'sid-a', 'internalKey' => 7, 'lasthit' => 1000, 'ip' => '10.0.0.1']]) + ->and((array) Capsule::table('active_users')->first()) + ->toBe(['sid' => 'sid-a', 'internalKey' => 7, 'username' => 'admin', 'lasthit' => 1000, 'action' => '27', 'id' => 42]) + ->and(Capsule::table('active_user_locks')->where('elementId', 42)->value('internalKey'))->toBe(7); +}); + +test('a session replaces the stale rows of the same user and of the same sid', function () { + Capsule::table('active_user_sessions')->insert([ + ['sid' => 'old-of-user', 'internalKey' => 7, 'lasthit' => 1, 'ip' => ''], + ['sid' => 'sid-a', 'internalKey' => 3, 'lasthit' => 1, 'ip' => ''], + ['sid' => 'someone-else', 'internalKey' => 9, 'lasthit' => 1, 'ip' => ''], + ]); + + ManagerActivity::instance()->touchSession(7, 'sid-a', 2000, '10.0.0.2'); + ManagerActivity::instance()->flush(); + + expect(Capsule::table('active_user_sessions')->orderBy('sid')->pluck('internalKey', 'sid')->all()) + ->toBe(['sid-a' => 7, 'someone-else' => 9]); +}); + +test('an action keeps one active_users row per user and refreshes an existing lock in place', function () { + Capsule::table('active_users')->insert(['sid' => 'old', 'internalKey' => 7, 'username' => 'admin', 'lasthit' => 1, 'action' => '3', 'id' => null]); + Capsule::table('active_user_locks')->insert(['sid' => 'old', 'internalKey' => 7, 'elementType' => 7, 'elementId' => 42, 'lasthit' => 1]); + + $activity = ManagerActivity::instance(); + $activity->setAction(7, 'new', 'admin', 5000, 27, 42); + $activity->lock(7, 42, 7, 'new', 5000); + $activity->flush(); + + expect(Capsule::table('active_users')->pluck('sid')->all())->toBe(['new']) + ->and(Capsule::table('active_user_locks')->count())->toBe(1) + ->and((array) Capsule::table('active_user_locks')->select('sid', 'lasthit')->first())->toBe(['sid' => 'new', 'lasthit' => 5000]); +}); + +test('flush runs queued jobs once, in order, and is a no-op afterwards', function () { + $order = []; + $activity = ManagerActivity::instance(); + $activity->defer(function () use (&$order) { $order[] = 'first'; }); + $activity->defer(function () use (&$order) { $order[] = 'second'; }); + + $activity->flush(); + $activity->flush(); + + expect($order)->toBe(['first', 'second']); + + $activity->defer(function () use (&$order) { $order[] = 'third'; }); + $activity->flush(); + + expect($order)->toBe(['first', 'second', 'third']); +}); + +test('a failing job rolls the whole batch back and surfaces the error when attached', function () { + $activity = ManagerActivity::instance(); + $activity->touchSession(7, 'sid-a', 1000, '10.0.0.1'); + $activity->defer(function () { throw new RuntimeException('boom'); }); + + expect(fn () => $activity->flush())->toThrow(RuntimeException::class, 'boom') + ->and(Capsule::table('active_user_sessions')->count())->toBe(0) + ->and($activity->hasPending())->toBeFalse(); +}); + +test('the manager hands its bookkeeping to the service and flushes after the response is sent', function () { + $core = file_get_contents(dirname(__DIR__, 3) . '/src/Core.php'); + $theme = file_get_contents(dirname(__DIR__, 3) . '/src/ManagerTheme.php'); + + expect($core)->toContain('ManagerActivity::instance()') + ->and($core)->not->toContain("ActiveUserSession::where('sid', \$this->sid)->delete()") + ->and($theme)->toContain('$response->send();') + ->and(strpos($theme, 'ManagerActivity::instance()->flush('))->toBeGreaterThan(strpos($theme, '$response->send();')) + ->and($theme)->not->toContain('->forceDelete()'); +}); diff --git a/core/tests/Unit/Support/DocumentPrivacyTest.php b/core/tests/Unit/Support/DocumentPrivacyTest.php new file mode 100644 index 0000000000..2e512f7ffc --- /dev/null +++ b/core/tests/Unit/Support/DocumentPrivacyTest.php @@ -0,0 +1,97 @@ + Facade::clearResolvedInstances()); + +/** + * Group 3 is linked to a web user group, group 4 to a manager user group, group 5 to nothing. + * Document 10 is in 3, 11 in 4, 12 in 5, 13 in 3 and 4, 14 has no group but stale flags. + */ +function bootPrivacyFixture(): Capsule +{ + $capsule = DocumentSaveDatabase::boot(); + foreach ([ + ['id' => 10, 'pagetitle' => 'web'], + ['id' => 11, 'pagetitle' => 'mgr'], + ['id' => 12, 'pagetitle' => 'unlinked group'], + ['id' => 13, 'pagetitle' => 'both', 'deleted' => 1], + ['id' => 14, 'pagetitle' => 'stale', 'privateweb' => 1, 'privatemgr' => 1], + ] as $row) { + Capsule::table('site_content')->insert($row); + } + Capsule::table('document_groups')->insert([ + ['document_group' => 3, 'document' => 10], + ['document_group' => 4, 'document' => 11], + ['document_group' => 5, 'document' => 12], + ['document_group' => 3, 'document' => 13], + ['document_group' => 4, 'document' => 13], + ]); + Capsule::table('membergroup_access')->insert([ + ['membergroup' => 1, 'documentgroup' => 3, 'context' => DocumentPrivacy::WEB], + ['membergroup' => 2, 'documentgroup' => 4, 'context' => DocumentPrivacy::MANAGER], + ]); + + return $capsule; +} + +function privacyOf(int $id): array +{ + $row = Capsule::table('site_content')->where('id', $id)->first(['privateweb', 'privatemgr']); + + return [(int) $row->privateweb, (int) $row->privatemgr]; +} + +test('refresh sets both flags of one document from the linked user groups', function () { + $capsule = bootPrivacyFixture(); + $capsule->getConnection()->enableQueryLog(); + + foreach ([10, 11, 12, 13, 14] as $id) { + DocumentPrivacy::refresh($id); + } + + expect($capsule->getConnection()->getQueryLog())->toHaveCount(10) + ->and(privacyOf(10))->toBe([1, 0]) + ->and(privacyOf(11))->toBe([0, 1]) + ->and(privacyOf(12))->toBe([0, 0]) + // trashed documents are refreshed too + ->and(privacyOf(13))->toBe([1, 1]) + ->and(privacyOf(14))->toBe([0, 0]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('refresh with a context leaves the other flag alone, as the legacy include did', function () { + bootPrivacyFixture(); + + DocumentPrivacy::refresh(14, DocumentPrivacy::WEB); + expect(privacyOf(14))->toBe([0, 1]); + + DocumentPrivacy::refresh(14, DocumentPrivacy::MANAGER); + expect(privacyOf(14))->toBe([0, 0]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('refreshAll recomputes one flag for the whole site and leaves the other alone', function () { + bootPrivacyFixture(); + + DocumentPrivacy::refreshAll(DocumentPrivacy::WEB); + + expect(privacyOf(10))->toBe([1, 0]) + ->and(privacyOf(13))->toBe([1, 0]) + ->and(privacyOf(14))->toBe([0, 1]); + + DocumentPrivacy::refreshAll(DocumentPrivacy::MANAGER); + + expect(privacyOf(11))->toBe([0, 1]) + ->and(privacyOf(13))->toBe([1, 1]) + ->and(privacyOf(14))->toBe([0, 0]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('the legacy include delegates to DocumentPrivacy', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/includes/secure_web_documents.inc.php'); + + expect($source)->toContain('DocumentPrivacy::refresh((int) $docid, $context)') + ->and($source)->toContain('DocumentPrivacy::refreshAll($context)') + ->and($source)->toContain('@deprecated since 3.5.9'); +}); diff --git a/core/tests/Unit/Support/DocumentSave/DocumentGroupSyncTest.php b/core/tests/Unit/Support/DocumentSave/DocumentGroupSyncTest.php new file mode 100644 index 0000000000..b29c001dd1 --- /dev/null +++ b/core/tests/Unit/Support/DocumentSave/DocumentGroupSyncTest.php @@ -0,0 +1,110 @@ + Facade::clearResolvedInstances()); + +function groupsOf(int $document): array +{ + return Capsule::table('document_groups')->where('document', $document)->orderBy('document_group')->pluck('document_group')->map(fn ($g) => (int) $g)->all(); +} + +test('posted pairs reduce to unique group ids', function () { + expect(DocumentGroupSync::postedGroups(['3,new', '5,17', '3,new', ['bad']]))->toBe([3, 5]); +}); + +test('a user locks themselves out only when none of the chosen groups is theirs', function () { + expect(DocumentGroupSync::locksOut([], [1]))->toBeFalse() + ->and(DocumentGroupSync::locksOut([3, 5], [5]))->toBeFalse() + ->and(DocumentGroupSync::locksOut([3, 5], [8]))->toBeTrue() + ->and(DocumentGroupSync::locksOut([3], []))->toBeTrue(); +}); + +test('without group permissions a new document inherits the groups of its parent', function () { + DocumentSaveDatabase::boot(); + + DocumentGroupSync::forNewDocument(50, ['3,new'], [7, 8], [3], false, false); + + expect(groupsOf(50))->toBe([7, 8]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('manage_groups attaches exactly the posted groups', function () { + DocumentSaveDatabase::boot(); + + DocumentGroupSync::forNewDocument(50, ['3,new', '5,new', '5,new'], [7], [], true, false); + + expect(groupsOf(50))->toBe([3, 5]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('manage_document_permissions keeps the parent groups the user cannot manage', function () { + DocumentSaveDatabase::boot(); + + // 9 is not the user's group and is dropped; 7 is a parent group outside the user's reach and stays + DocumentGroupSync::forNewDocument(50, ['3,new', '9,new'], [7, 3], [3], false, true); + + expect(groupsOf(50))->toBe([3, 7]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a user who picks none of their own groups keeps all groups of the parent', function () { + DocumentSaveDatabase::boot(); + + DocumentGroupSync::forNewDocument(50, [], [7, 3], [3], false, true); + + expect(groupsOf(50))->toBe([3, 7]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('editing inserts the new pairs and deletes the unchecked ones', function () { + DocumentSaveDatabase::boot(); + Capsule::table('document_groups')->insert([ + ['id' => 1, 'document_group' => 3, 'document' => 50], + ['id' => 2, 'document_group' => 4, 'document' => 50], + ['id' => 3, 'document_group' => 4, 'document' => 51], + ]); + + $kept = DocumentGroupSync::forExistingDocument(50, ['3,1', '5,new'], [], true, false); + + expect($kept)->toBeTrue() + ->and(groupsOf(50))->toBe([3, 5]) + ->and(groupsOf(51))->toBe([4]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a user without manage_groups cannot touch groups outside their own', function () { + DocumentSaveDatabase::boot(); + Capsule::table('document_groups')->insert([ + ['id' => 1, 'document_group' => 3, 'document' => 50], + ['id' => 2, 'document_group' => 8, 'document' => 50], + ]); + + // tries to drop 8 (not theirs) and add 9 (not theirs), adds 5 (theirs) + $kept = DocumentGroupSync::forExistingDocument(50, ['3,1', '5,new', '9,new'], [3, 5], false, false); + + expect($kept)->toBeTrue() + ->and(groupsOf(50))->toBe([3, 5, 8]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('removing the last own group is refused before anything is written', function () { + DocumentSaveDatabase::boot(); + Capsule::table('document_groups')->insert([ + ['id' => 1, 'document_group' => 3, 'document' => 50], + ['id' => 2, 'document_group' => 8, 'document' => 50], + ]); + + $kept = DocumentGroupSync::forExistingDocument(50, [], [3], false, false); + + expect($kept)->toBeFalse() + ->and(groupsOf(50))->toBe([3, 8]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('making the document public removes every group', function () { + DocumentSaveDatabase::boot(); + Capsule::table('document_groups')->insert([ + ['id' => 1, 'document_group' => 3, 'document' => 50], + ['id' => 2, 'document_group' => 8, 'document' => 50], + ]); + + expect(DocumentGroupSync::forExistingDocument(50, [], [], true, true))->toBeTrue() + ->and(groupsOf(50))->toBe([]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); diff --git a/core/tests/Unit/Support/DocumentSave/PublishStateTest.php b/core/tests/Unit/Support/DocumentSave/PublishStateTest.php new file mode 100644 index 0000000000..1d4b10189a --- /dev/null +++ b/core/tests/Unit/Support/DocumentSave/PublishStateTest.php @@ -0,0 +1,44 @@ +toBe(1) + ->and(PublishState::fromDates(1, PS_NOW + 10, 0, PS_NOW))->toBe(0) + ->and(PublishState::fromDates(1, 0, PS_NOW - 10, PS_NOW))->toBe(0) + ->and(PublishState::fromDates(1, 0, PS_NOW + 10, PS_NOW))->toBe(1) + ->and(PublishState::fromDates(1, 0, 0, PS_NOW))->toBe(1); +}); + +test('a new document without publish_document is always unpublished with no dates', function () { + expect(PublishState::forNew(1, PS_NOW - 5, PS_NOW + 5, PS_NOW, 7, false)) + ->toBe(['published' => 0, 'pub_date' => 0, 'unpub_date' => 0, 'publishedon' => 0, 'publishedby' => 0]); +}); + +test('a new published document records the publisher and uses the publish date when set', function () { + expect(PublishState::forNew(1, 0, 0, PS_NOW, 7, true)) + ->toBe(['published' => 1, 'pub_date' => 0, 'unpub_date' => 0, 'publishedon' => PS_NOW, 'publishedby' => 7]) + ->and(PublishState::forNew(0, PS_NOW - 100, 0, PS_NOW, 7, true)) + ->toBe(['published' => 1, 'pub_date' => PS_NOW - 100, 'unpub_date' => 0, 'publishedon' => PS_NOW - 100, 'publishedby' => 7]); +}); + +test('editing without publish_document keeps the stored state and dates', function () { + $existing = ['published' => 1, 'pub_date' => 11, 'unpub_date' => 22, 'publishedon' => 33, 'publishedby' => 4]; + + // the legacy processor wrote the literal strings 'pub_date' / 'unpub_date' here + expect(PublishState::forEdit(0, 0, 0, PS_NOW, 7, false, $existing)) + ->toBe(['published' => 1, 'pub_date' => 11, 'unpub_date' => 22, 'publishedon' => 33, 'publishedby' => 4]); +}); + +test('editing tracks the publish transition', function () { + $unpublished = ['published' => 0, 'pub_date' => 0, 'unpub_date' => 0, 'publishedon' => 0, 'publishedby' => 0]; + $published = ['published' => 1, 'pub_date' => 0, 'unpub_date' => 0, 'publishedon' => 500, 'publishedby' => 4]; + + expect(PublishState::forEdit(1, 0, 0, PS_NOW, 7, true, $unpublished)['publishedon'])->toBe(PS_NOW) + ->and(PublishState::forEdit(1, 0, 0, PS_NOW, 7, true, $unpublished)['publishedby'])->toBe(7) + ->and(PublishState::forEdit(0, 0, 0, PS_NOW, 7, true, $published))->toMatchArray(['publishedon' => 0, 'publishedby' => 0]) + ->and(PublishState::forEdit(1, 0, 0, PS_NOW, 7, true, $published))->toMatchArray(['publishedon' => 500, 'publishedby' => 4]) + ->and(PublishState::forEdit(1, PS_NOW - 50, 0, PS_NOW, 7, true, $published))->toMatchArray(['publishedon' => PS_NOW - 50, 'publishedby' => 7]); +}); diff --git a/core/tests/Unit/Support/DocumentSave/SaveResponseTest.php b/core/tests/Unit/Support/DocumentSave/SaveResponseTest.php new file mode 100644 index 0000000000..eb74395869 --- /dev/null +++ b/core/tests/Unit/Support/DocumentSave/SaveResponseTest.php @@ -0,0 +1,67 @@ +toBe('index.php?a=27&id=12&r=1&stay=2&dir=ASC&sort=pagetitle&page=2') + ->and(SaveResponse::redirectUrl($created, '2', false, '&dir=ASC', 'http://s/')) + ->toBe('index.php?a=27&id=40&r=1&stay=2'); +}); + +test('close goes to the resource overview and "add another" to the editor of the same type', function () use ($edited, $created) { + expect(SaveResponse::redirectUrl($edited, '', false, '', 'http://s/'))->toBe('index.php?a=3&id=12&r=1') + ->and(SaveResponse::redirectUrl($edited, '1', false, '', 'http://s/'))->toBe('index.php?a=4&pid=3&r=1&stay=1') + // a weblink opens the weblink form, whatever the original mode was + ->and(SaveResponse::redirectUrl($created, '1', false, '', 'http://s/'))->toBe('index.php?a=72&pid=3&r=1&stay=1'); +}); + +test('the preview flow only exists for an existing resource', function () use ($edited, $created) { + expect(SaveResponse::redirectUrl($edited, '2', true, '&dir=ASC', 'http://s/'))->toBe('http://s/index.php?id=12&z=manprev') + ->and(SaveResponse::redirectUrl($created, '2', true, '', 'http://s/'))->toBe('index.php?a=27&id=40&r=1&stay=2'); +}); + +test('the JSON answer carries what the editor writes back plus where the form flow would go', function () use ($edited) { + expect(SaveResponse::payload($edited, 'index.php?a=27&id=12', 'tok'))->toBe([ + 'success' => true, + 'id' => 12, + 'mode' => 'edit', + 'type' => 'document', + 'parent' => 3, + 'pagetitle' => 'Page', + 'alias' => 'page', + 'editedon' => 1_700_000_000, + 'redirect' => 'index.php?a=27&id=12', + 'token' => 'tok', + ]); +}); + +test('the save processor answers JSON to the in-place save and redirects everyone else', function () { + $root = dirname(__DIR__, 5); + $processor = file_get_contents($root . '/manager/processors/save_content.processor.php'); + $editor = file_get_contents($root . '/manager/actions/mutate_content.dynamic.php'); + + expect($processor)->toContain('$ajax = is_ajax();') + ->and($processor)->toContain("\$respondJson(200, \\EvolutionCMS\\Support\\DocumentSave\\SaveResponse::payload(\$saved, \$redirectUrl, csrf_token()));") + // plugin output during the save is buffered away from the JSON + ->and(substr_count($processor, 'ob_end_clean();'))->toBe(2) + ->and($processor)->toContain("if (\$ajax) {\n ob_start();\n}") + ->and($processor)->toContain("\$respondJson(422, ['success' => false, 'message' => \$denied->getMessage()]);") + ->and($processor)->toContain("\$respondJson(403, ['success' => false, 'message' => __(\"global.error_no_privileges\")]);") + ->and($processor)->toContain('SaveResponse::redirectUrl($saved, $stay, $refreshPreview, $add_path, EVO_SITE_URL)') + // the in-place save posts to index.php?a=5 with the XHR marker; the form post stays the fallback + ->and($editor)->toContain("revision(MGR_DIR . '/media/script/document-save-helper.js')") + ->and($editor)->toContain('ajaxSaveHelper.usesAjax(document.mutate)') + ->and($editor)->toContain("xhr.open('POST', ajaxSaveHelper.requestUrl(form.a.value), true);") + ->and($editor)->toContain("xhr.setRequestHeader('X-REQUESTED-WITH', 'XMLHttpRequest');") + ->and($editor)->toContain('xhr.send(ajaxSaveHelper.requestBody(new FormData(form), tokenMeta));') + // a save that cannot be confirmed is never replayed + ->and($editor)->not->toContain('classicSave') + ->and($editor)->toContain("js_json(\$_lang['resource_save_unconfirmed'])") + ->and($editor)->toContain("button.classList.add('saved');") + ->and($editor)->toContain('parent.evo.tree.restoreTree()'); +}); diff --git a/core/tests/Unit/Support/DocumentSave/TemplateVariableInputTest.php b/core/tests/Unit/Support/DocumentSave/TemplateVariableInputTest.php new file mode 100644 index 0000000000..29e467d223 --- /dev/null +++ b/core/tests/Unit/Support/DocumentSave/TemplateVariableInputTest.php @@ -0,0 +1,42 @@ + 3, 'type' => 'text', 'default_text' => 'dflt']; + +test('a plain value is stored as posted', function () use ($text) { + expect(TemplateVariableInput::value($text, ['tv3' => 'hello']))->toBe('hello'); +}); + +test('empty values, "0" and the default text mean "remove the row"', function () use ($text) { + expect(TemplateVariableInput::value($text, []))->toBeNull() + ->and(TemplateVariableInput::value($text, ['tv3' => '']))->toBeNull() + // the legacy processor treated "0" as empty; the row is dropped and the default shows + ->and(TemplateVariableInput::value($text, ['tv3' => '0']))->toBeNull() + ->and(TemplateVariableInput::value($text, ['tv3' => 'dflt']))->toBeNull(); +}); + +test('checkboxes and multiple selects are joined with the || delimiter', function () use ($text) { + expect(TemplateVariableInput::value($text, ['tv3' => ['a' => 'one', 'b' => 'two']]))->toBe('one||two'); +}); + +test('url fields get the chosen prefix after stripping any scheme the user typed', function () { + $url = ['id' => 5, 'type' => 'url', 'default_text' => '']; + + expect(TemplateVariableInput::value($url, ['tv5' => 'http://example.org', 'tv5_prefix' => 'https://']))->toBe('https://example.org') + ->and(TemplateVariableInput::value($url, ['tv5' => 'mailto:me@example.org', 'tv5_prefix' => '--']))->toBe('mailto:me@example.org') + ->and(TemplateVariableInput::value($url, ['tv5' => 'example.org']))->toBe('example.org'); +}); + +test('file fields ignore array input', function () { + $file = ['id' => 6, 'type' => 'file', 'default_text' => '']; + + expect(TemplateVariableInput::value($file, ['tv6' => 'assets/a.pdf']))->toBe('assets/a.pdf') + ->and(TemplateVariableInput::value($file, ['tv6' => ['x']]))->toBeNull(); +}); + +test('values() keys the desired value by tv id for every tv of the template', function () use ($text) { + $tvs = [$text, ['id' => 4, 'type' => 'text', 'default_text' => '']]; + + expect(TemplateVariableInput::values($tvs, ['tv3' => 'x']))->toBe([3 => 'x', 4 => null]); +}); diff --git a/core/tests/Unit/Support/DocumentSave/TemplateVariableValuesTest.php b/core/tests/Unit/Support/DocumentSave/TemplateVariableValuesTest.php new file mode 100644 index 0000000000..36f3bc4808 --- /dev/null +++ b/core/tests/Unit/Support/DocumentSave/TemplateVariableValuesTest.php @@ -0,0 +1,85 @@ + Facade::clearResolvedInstances()); + +/** + * Template 1 has TVs 1..3; TV 3 is restricted to document group 9. Document 100 stores TVs 1 and 2. + */ +function bootTvFixture(): Capsule +{ + $capsule = DocumentSaveDatabase::boot(); + Capsule::table('site_tmplvars')->insert([ + ['id' => 1, 'name' => 'one', 'type' => 'text', 'default_text' => '', 'rank' => 2], + ['id' => 2, 'name' => 'two', 'type' => 'text', 'default_text' => 'd2', 'rank' => 1], + ['id' => 3, 'name' => 'three', 'type' => 'text', 'default_text' => '', 'rank' => 3], + ['id' => 4, 'name' => 'other-template', 'type' => 'text', 'default_text' => '', 'rank' => 0], + ]); + Capsule::table('site_tmplvar_templates')->insert([ + ['tmplvarid' => 1, 'templateid' => 1], ['tmplvarid' => 2, 'templateid' => 1], + ['tmplvarid' => 3, 'templateid' => 1], ['tmplvarid' => 4, 'templateid' => 2], + ]); + Capsule::table('site_tmplvar_access')->insert([['tmplvarid' => 3, 'documentgroup' => 9]]); + Capsule::table('site_tmplvar_contentvalues')->insert([ + ['id' => 10, 'tmplvarid' => 1, 'contentid' => 100, 'value' => 'v1'], + ['id' => 11, 'tmplvarid' => 2, 'contentid' => 100, 'value' => 'v2'], + ['id' => 12, 'tmplvarid' => 1, 'contentid' => 200, 'value' => 'other doc'], + ]); + + return $capsule; +} + +test('an administrator gets every tv of the template, ordered by rank, with the stored value', function () { + bootTvFixture(); + + $rows = TemplateVariableValues::forTemplate(1, 100, false, []); + + expect(array_column($rows, 'id'))->toBe([2, 1, 3]) + ->and($rows[0])->toMatchArray(['value_id' => 11, 'value' => 'v2', 'default_text' => 'd2']) + ->and($rows[2])->toMatchArray(['value_id' => null, 'value' => null]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a restricted tv is hidden from a user whose document is not in its group', function () { + bootTvFixture(); + + expect(array_column(TemplateVariableValues::forTemplate(1, 100, true, [5]), 'id'))->toBe([2, 1]); + + Capsule::table('document_groups')->insert(['document_group' => 5, 'document' => 100]); + Capsule::table('site_tmplvar_contentvalues')->insert(['tmplvarid' => 3, 'contentid' => 100, 'value' => 'v3']); + + expect(array_column(TemplateVariableValues::forTemplate(1, 100, true, [5]), 'id'))->toBe([2, 1, 3]); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('sync writes only the differences and leaves other documents alone', function () { + $capsule = bootTvFixture(); + $tvs = TemplateVariableValues::forTemplate(1, 100, false, []); + + $capsule->getConnection()->enableQueryLog(); + // TV 1 unchanged, TV 2 removed, TV 3 added + TemplateVariableValues::sync(100, $tvs, [1 => 'v1', 2 => null, 3 => 'new']); + + $log = array_column($capsule->getConnection()->getQueryLog(), 'query'); + expect($log)->toHaveCount(2) + ->and($log[0])->toStartWith('insert into') + ->and($log[1])->toStartWith('delete from') + ->and(Capsule::table('site_tmplvar_contentvalues')->where('contentid', 100)->orderBy('tmplvarid')->pluck('value', 'tmplvarid')->all()) + ->toBe([1 => 'v1', 3 => 'new']) + ->and(Capsule::table('site_tmplvar_contentvalues')->where('id', 12)->value('value'))->toBe('other doc'); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); + +test('a changed value is updated in place and an unchanged form costs no query', function () { + $capsule = bootTvFixture(); + $tvs = TemplateVariableValues::forTemplate(1, 100, false, []); + + $capsule->getConnection()->enableQueryLog(); + TemplateVariableValues::sync(100, $tvs, [1 => 'v1', 2 => 'v2', 3 => null]); + expect($capsule->getConnection()->getQueryLog())->toBe([]); + + TemplateVariableValues::sync(100, $tvs, [1 => 'changed', 2 => 'v2', 3 => null]); + expect($capsule->getConnection()->getQueryLog())->toHaveCount(1) + ->and(Capsule::table('site_tmplvar_contentvalues')->where('id', 10)->value('value'))->toBe('changed'); +})->skip(!extension_loaded('pdo_sqlite'), 'pdo_sqlite is required'); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index cf73027e97..da4da0b094 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1369,6 +1369,11 @@ 'EvolutionCMS\\Services\\ComposerVersionSynchronizer' => $baseDir . '/src/Services/ComposerVersionSynchronizer.php', 'EvolutionCMS\\Services\\ConfigService' => $baseDir . '/src/Services/ConfigService.php', 'EvolutionCMS\\Services\\DatabaseBackupService' => $baseDir . '/src/Services/DatabaseBackupService.php', + 'EvolutionCMS\\Services\\DocumentSaveService' => $baseDir . '/src/Services/DocumentSaveService.php', + 'EvolutionCMS\\Services\\DocumentSave\\DocumentSaveContext' => $baseDir . '/src/Services/DocumentSave/DocumentSaveContext.php', + 'EvolutionCMS\\Services\\DocumentSave\\DocumentSaveDenied' => $baseDir . '/src/Services/DocumentSave/DocumentSaveDenied.php', + 'EvolutionCMS\\Services\\DocumentSave\\DocumentSaveResult' => $baseDir . '/src/Services/DocumentSave/DocumentSaveResult.php', + 'EvolutionCMS\\Services\\ManagerActivity' => $baseDir . '/src/Services/ManagerActivity.php', 'EvolutionCMS\\Services\\PasswordRecoveryService' => $baseDir . '/src/Services/PasswordRecoveryService.php', 'EvolutionCMS\\Services\\Store\\CatalogService' => $baseDir . '/src/Services/Store/CatalogService.php', 'EvolutionCMS\\Services\\Store\\InstalledStateService' => $baseDir . '/src/Services/Store/InstalledStateService.php', @@ -1401,6 +1406,12 @@ 'EvolutionCMS\\Support\\DataGrid' => $baseDir . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => $baseDir . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => $baseDir . '/src/Support/DocBlock.php', + 'EvolutionCMS\\Support\\DocumentPrivacy' => $baseDir . '/src/Support/DocumentPrivacy.php', + 'EvolutionCMS\\Support\\DocumentSave\\DocumentGroupSync' => $baseDir . '/src/Support/DocumentSave/DocumentGroupSync.php', + 'EvolutionCMS\\Support\\DocumentSave\\PublishState' => $baseDir . '/src/Support/DocumentSave/PublishState.php', + 'EvolutionCMS\\Support\\DocumentSave\\SaveResponse' => $baseDir . '/src/Support/DocumentSave/SaveResponse.php', + 'EvolutionCMS\\Support\\DocumentSave\\TemplateVariableInput' => $baseDir . '/src/Support/DocumentSave/TemplateVariableInput.php', + 'EvolutionCMS\\Support\\DocumentSave\\TemplateVariableValues' => $baseDir . '/src/Support/DocumentSave/TemplateVariableValues.php', 'EvolutionCMS\\Support\\FileManagerAccess' => $baseDir . '/src/Support/FileManagerAccess.php', 'EvolutionCMS\\Support\\Formatter\\CSSMinify' => $baseDir . '/src/Support/Formatter/CSSMinify.php', 'EvolutionCMS\\Support\\Formatter\\HtmlFormatter' => $baseDir . '/src/Support/Formatter/HtmlFormatter.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index e21a21ce48..9add00eab6 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2046,6 +2046,11 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Services\\ComposerVersionSynchronizer' => __DIR__ . '/../..' . '/src/Services/ComposerVersionSynchronizer.php', 'EvolutionCMS\\Services\\ConfigService' => __DIR__ . '/../..' . '/src/Services/ConfigService.php', 'EvolutionCMS\\Services\\DatabaseBackupService' => __DIR__ . '/../..' . '/src/Services/DatabaseBackupService.php', + 'EvolutionCMS\\Services\\DocumentSaveService' => __DIR__ . '/../..' . '/src/Services/DocumentSaveService.php', + 'EvolutionCMS\\Services\\DocumentSave\\DocumentSaveContext' => __DIR__ . '/../..' . '/src/Services/DocumentSave/DocumentSaveContext.php', + 'EvolutionCMS\\Services\\DocumentSave\\DocumentSaveDenied' => __DIR__ . '/../..' . '/src/Services/DocumentSave/DocumentSaveDenied.php', + 'EvolutionCMS\\Services\\DocumentSave\\DocumentSaveResult' => __DIR__ . '/../..' . '/src/Services/DocumentSave/DocumentSaveResult.php', + 'EvolutionCMS\\Services\\ManagerActivity' => __DIR__ . '/../..' . '/src/Services/ManagerActivity.php', 'EvolutionCMS\\Services\\PasswordRecoveryService' => __DIR__ . '/../..' . '/src/Services/PasswordRecoveryService.php', 'EvolutionCMS\\Services\\Store\\CatalogService' => __DIR__ . '/../..' . '/src/Services/Store/CatalogService.php', 'EvolutionCMS\\Services\\Store\\InstalledStateService' => __DIR__ . '/../..' . '/src/Services/Store/InstalledStateService.php', @@ -2078,6 +2083,12 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\DataGrid' => __DIR__ . '/../..' . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => __DIR__ . '/../..' . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => __DIR__ . '/../..' . '/src/Support/DocBlock.php', + 'EvolutionCMS\\Support\\DocumentPrivacy' => __DIR__ . '/../..' . '/src/Support/DocumentPrivacy.php', + 'EvolutionCMS\\Support\\DocumentSave\\DocumentGroupSync' => __DIR__ . '/../..' . '/src/Support/DocumentSave/DocumentGroupSync.php', + 'EvolutionCMS\\Support\\DocumentSave\\PublishState' => __DIR__ . '/../..' . '/src/Support/DocumentSave/PublishState.php', + 'EvolutionCMS\\Support\\DocumentSave\\SaveResponse' => __DIR__ . '/../..' . '/src/Support/DocumentSave/SaveResponse.php', + 'EvolutionCMS\\Support\\DocumentSave\\TemplateVariableInput' => __DIR__ . '/../..' . '/src/Support/DocumentSave/TemplateVariableInput.php', + 'EvolutionCMS\\Support\\DocumentSave\\TemplateVariableValues' => __DIR__ . '/../..' . '/src/Support/DocumentSave/TemplateVariableValues.php', 'EvolutionCMS\\Support\\FileManagerAccess' => __DIR__ . '/../..' . '/src/Support/FileManagerAccess.php', 'EvolutionCMS\\Support\\Formatter\\CSSMinify' => __DIR__ . '/../..' . '/src/Support/Formatter/CSSMinify.php', 'EvolutionCMS\\Support\\Formatter\\HtmlFormatter' => __DIR__ . '/../..' . '/src/Support/Formatter/HtmlFormatter.php', diff --git a/core/vendor/evolutioncms-services/user-manager/src/Services/Users/UserLogin.php b/core/vendor/evolutioncms-services/user-manager/src/Services/Users/UserLogin.php index e0838d4ec7..1223844ff5 100644 --- a/core/vendor/evolutioncms-services/user-manager/src/Services/Users/UserLogin.php +++ b/core/vendor/evolutioncms-services/user-manager/src/Services/Users/UserLogin.php @@ -284,7 +284,7 @@ public function authProcess() EvolutionCMS()->cleanupExpiredLocks(); EvolutionCMS()->cleanupMultipleActiveUsers(); - if(!defined('NO_SESSION')) { + if (!(class_exists('EvoSessionProxy', false) && \EvoSessionProxy::disabled())) { $this->writeSession(); } // successful login so reset fail count and update key values diff --git a/manager/actions/mutate_content.dynamic.php b/manager/actions/mutate_content.dynamic.php index 289f6dd01a..c4c3fe75d8 100644 --- a/manager/actions/mutate_content.dynamic.php +++ b/manager/actions/mutate_content.dynamic.php @@ -163,12 +163,66 @@ .image_for_field[data-image=""] { display: none } +