From d2f8f4be772cde374d22940b252baff00daa0508 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 21 Sep 2026 15:54:44 +0200 Subject: [PATCH 1/5] ref(manager): document save speedup by splitting save_content.processor and reducing database calls --- .../DocumentSave/DocumentSaveContext.php | 132 ++++ .../DocumentSave/DocumentSaveDenied.php | 20 + .../DocumentSave/DocumentSaveResult.php | 27 + core/src/Services/DocumentSaveService.php | 294 ++++++++ core/src/Support/DocumentPrivacy.php | 65 ++ .../DocumentSave/DocumentGroupSync.php | 144 ++++ .../src/Support/DocumentSave/PublishState.php | 89 +++ .../DocumentSave/TemplateVariableInput.php | 62 ++ .../DocumentSave/TemplateVariableValues.php | 89 +++ core/tests/Support/DocumentSaveDatabase.php | 169 +++++ .../Unit/Services/DocumentSaveServiceTest.php | 297 ++++++++ .../Unit/Support/DocumentPrivacyTest.php | 97 +++ .../DocumentSave/DocumentGroupSyncTest.php | 110 +++ .../Support/DocumentSave/PublishStateTest.php | 44 ++ .../TemplateVariableInputTest.php | 42 ++ .../TemplateVariableValuesTest.php | 85 +++ core/vendor/composer/autoload_classmap.php | 9 + core/vendor/composer/autoload_static.php | 9 + manager/includes/secure_web_documents.inc.php | 31 +- manager/processors/save_content.processor.php | 703 +----------------- .../web_access_groups.processor.php | 7 +- 21 files changed, 1834 insertions(+), 691 deletions(-) create mode 100644 core/src/Services/DocumentSave/DocumentSaveContext.php create mode 100644 core/src/Services/DocumentSave/DocumentSaveDenied.php create mode 100644 core/src/Services/DocumentSave/DocumentSaveResult.php create mode 100644 core/src/Services/DocumentSaveService.php create mode 100644 core/src/Support/DocumentPrivacy.php create mode 100644 core/src/Support/DocumentSave/DocumentGroupSync.php create mode 100644 core/src/Support/DocumentSave/PublishState.php create mode 100644 core/src/Support/DocumentSave/TemplateVariableInput.php create mode 100644 core/src/Support/DocumentSave/TemplateVariableValues.php create mode 100644 core/tests/Support/DocumentSaveDatabase.php create mode 100644 core/tests/Unit/Services/DocumentSaveServiceTest.php create mode 100644 core/tests/Unit/Support/DocumentPrivacyTest.php create mode 100644 core/tests/Unit/Support/DocumentSave/DocumentGroupSyncTest.php create mode 100644 core/tests/Unit/Support/DocumentSave/PublishStateTest.php create mode 100644 core/tests/Unit/Support/DocumentSave/TemplateVariableInputTest.php create mode 100644 core/tests/Unit/Support/DocumentSave/TemplateVariableValuesTest.php 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..a90883dd18 --- /dev/null +++ b/core/src/Services/DocumentSaveService.php @@ -0,0 +1,294 @@ +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()]); + + $id = $this->transaction(function () use ($fields, $tvs, $tvValues, $parent, $parentRow, $groupPairs, $usePermissions) { + $id = (int) SiteContent::withTrashed()->create($fields)->getKey(); + 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]); + + $this->transaction(function () use ($existing, $id, $fields, $tvs, $tvValues, $parent, $oldParent, $parentRow, $groupPairs, $makePublic, $usePermissions) { + foreach ($fields as $field => $value) { + $existing->{$field} = $value; + } + $existing->save(); + 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); + } + + /** + * 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/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/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/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/Services/DocumentSaveServiceTest.php b/core/tests/Unit/Services/DocumentSaveServiceTest.php new file mode 100644 index 0000000000..0babfdce84 --- /dev/null +++ b/core/tests/Unit/Services/DocumentSaveServiceTest.php @@ -0,0 +1,297 @@ + 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(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/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/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..e90269da6a 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1369,6 +1369,10 @@ '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\\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 +1405,11 @@ '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\\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..f9a88eea13 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2046,6 +2046,10 @@ 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\\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 +2082,11 @@ 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\\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/manager/includes/secure_web_documents.inc.php b/manager/includes/secure_web_documents.inc.php index 125859cc44..c5f951f1e4 100755 --- a/manager/includes/secure_web_documents.inc.php +++ b/manager/includes/secure_web_documents.inc.php @@ -3,6 +3,8 @@ die("INCLUDE_ORDERING_ERROR

Please use the EVO Content Manager instead of accessing this file directly."); } +use EvolutionCMS\Support\DocumentPrivacy; + /** * Secure Web Documents * This script will mark web documents as private @@ -11,34 +13,23 @@ * is assigned to the document group that the document belongs to. * * @param string $docid + * @deprecated since 3.5.9 use EvolutionCMS\Support\DocumentPrivacy + * @todo [remove@3.7] Remove in Evolution CMS 3.7 */ function secureWebDocument($docid = '', $context = 1) { - $context = $context == 0 ? 0 : 1; - $privateField = $context ? 'privateweb' : 'privatemgr'; + $context = $context == 0 ? DocumentPrivacy::MANAGER : DocumentPrivacy::WEB; if (is_numeric($docid) && $docid > 0) { - \EvolutionCMS\Models\SiteContent::withTrashed()->find($docid)->update([$privateField => 0]); + DocumentPrivacy::refresh((int) $docid, $context); } else { - \EvolutionCMS\Models\SiteContent::withTrashed()->where($privateField, 1)->update([$privateField => 0]); - } - - $documentIds = \EvolutionCMS\Models\SiteContent::withTrashed()->select('site_content.id')->distinct() - ->leftJoin('document_groups', 'site_content.id', '=', 'document_groups.document') - ->leftJoin('membergroup_access', function(Illuminate\Database\Query\JoinClause $join) use ($context) { - $join->on('document_groups.document_group', '=', 'membergroup_access.documentgroup') - ->where('membergroup_access.context', '=', $context); - })->where('membergroup_access.id', '>', 0); - if (is_numeric($docid) && $docid > 0) { - $documentIds = $documentIds->where('site_content.id', $docid); - } - - $ids = $documentIds->get()->pluck('id'); - - if (count($ids) > 0) { - \EvolutionCMS\Models\SiteContent::withTrashed()->whereIn('id', $ids)->update([$privateField => 1]); + DocumentPrivacy::refreshAll($context); } } +/** + * @deprecated since 3.5.9 use EvolutionCMS\Support\DocumentPrivacy + * @todo [remove@3.7] Remove in Evolution CMS 3.7 + */ function secureMgrDocument($docid = '', $context = 0) { secureWebDocument($docid, $context); diff --git a/manager/processors/save_content.processor.php b/manager/processors/save_content.processor.php index 1bfbb3fc77..1184d67b4c 100755 --- a/manager/processors/save_content.processor.php +++ b/manager/processors/save_content.processor.php @@ -7,35 +7,11 @@ evo()->webAlertAndQuit(__("global.error_no_privileges")); } -// preprocess POST values -$id = is_numeric($_POST['id']) ? $_POST['id'] : ''; - -$introtext = $_POST['introtext'] ?? ''; -$content = $_POST['ta'] ?? ''; -$pagetitle = $_POST['pagetitle'] ?? ''; -$description = $_POST['description'] ?? ''; -$alias = $_POST['alias']; -$link_attributes = $_POST['link_attributes']; -$isfolder = (int)$_POST['isfolder']; -$richtext = (int)$_POST['richtext']; -$published = (int)$_POST['published']; -$parentId = $parent = (int)get_by_key($_POST, 'parent', 0, 'is_scalar'); -$template = (int)$_POST['template']; -$menuindex = !empty($_POST['menuindex']) ? (int)$_POST['menuindex'] : 0; -$searchable = (int)$_POST['searchable']; -$cacheable = (int)$_POST['cacheable']; -$syncsite = (int)$_POST['syncsite']; -$pub_date = $_POST['pub_date']; -$unpub_date = $_POST['unpub_date']; -$document_groups = (isset($_POST['chkalldocs']) && $_POST['chkalldocs'] == 'on') ? [] : get_by_key($_POST, 'docgroups', [], 'is_array'); -$type = $_POST['type']; -$contentType = $_POST['contentType']; -$contentdispo = (int)$_POST['content_dispo']; -$longtitle = $_POST['longtitle']; -$hide_from_tree = (int)$_POST['hide_from_tree']; -$menutitle = $_POST['menutitle']; -$hidemenu = (int)$_POST['hidemenu']; -$aliasvisible = (int)$_POST['alias_visible']; +$id = is_numeric($_POST['id'] ?? null) ? (int)$_POST['id'] : 0; +$type = $_POST['type'] ?? 'document'; +$parentId = (int)get_by_key($_POST, 'parent', 0, 'is_scalar'); +$syncsite = (int)($_POST['syncsite'] ?? 0); +$stay = (string)($_POST['stay'] ?? ''); /************* webber ********/ $sd=isset($_POST['dir']) && strtolower($_POST['dir']) === 'asc' ? '&dir=ASC' : '&dir=DESC'; @@ -43,659 +19,56 @@ $pg=isset($_POST['page'])?'&page='.(int)$_POST['page']:''; $add_path=$sd.$sb.$pg; - - -$no_esc_pagetitle = $_POST['pagetitle']; -if (trim($no_esc_pagetitle) == "") { - if ($type == "reference") { - $no_esc_pagetitle = $pagetitle = $_lang['untitled_weblink']; - } else { - $no_esc_pagetitle = $pagetitle = $_lang['untitled_resource']; - } -} - - -$actionToTake = ((int)$id > 0 || $_POST['mode'] == '73' || $_POST['mode'] == '27') ? "edit" : "new"; +$actionToTake = ($id > 0 || ($_POST['mode'] ?? '') == '73' || ($_POST['mode'] ?? '') == '27') ? "edit" : "new"; $newResourceAction = ($type == "reference") ? "72" : "4"; $editResourceAction = "27"; $newResourceRedirect = "index.php?a={$newResourceAction}"; $editResourceRedirect = "index.php?a={$editResourceAction}&id={$id}"; -// friendly url alias checks -if ($modx->getConfig('friendly_urls')) { - // auto assign alias - if (!$alias && $modx->getConfig('automatic_alias')) { - $alias = strtolower($modx->stripAlias(trim($pagetitle))); - if(!$modx->getConfig('allow_duplicate_alias')) { - - if (\EvolutionCMS\Models\SiteContent::withTrashed() - ->where('id', '<>', $id) - ->where('alias', $alias)->count() > 0) { - $cnt = 1; - $tempAlias = $alias; - - while (\EvolutionCMS\Models\SiteContent::withTrashed() - ->where('id', '<>', $id) - ->where('alias', $tempAlias)->count() > 0) { - $tempAlias = $alias; - $tempAlias .= $cnt; - $cnt++; - } - $alias = $tempAlias; - } - }else{ - if (\EvolutionCMS\Models\SiteContent::withTrashed() - ->where('id', '<>', $id) - ->where('alias', $alias) - ->where('parent', $parent)->count() > 0) { - $cnt = 1; - $tempAlias = $alias; - while (\EvolutionCMS\Models\SiteContent::withTrashed() - ->where('id', '<>', $id) - ->where('alias', $tempAlias) - ->where('parent', $parent)->count() > 0) { - $tempAlias = $alias; - $tempAlias .= $cnt; - $cnt++; - } - $alias = $tempAlias; - } - } - } - - // check for duplicate alias name if not allowed - elseif ($alias && !$modx->getConfig('allow_duplicate_alias')) { - $alias = $modx->stripAlias($alias); - $docid = \EvolutionCMS\Models\SiteContent::withTrashed()->select('id') - ->where('id', '<>', $id) - ->where('alias', $alias); - if ($modx->getConfig('use_alias_path')) { - // only check for duplicates on the same level if alias_path is on - $docid = $docid->where('parent', $parent); - } - $docid = $docid->first(); - if (!is_null($docid)) { - if ($actionToTake == 'edit') { - $modx->getManagerApi()->saveFormValues($editResourceAction); - $modx->webAlertAndQuit(sprintf($_lang["duplicate_alias_found"], $docid->id, $alias), $editResourceRedirect); - } else { - $modx->getManagerApi()->saveFormValues($newResourceAction); - $modx->webAlertAndQuit(sprintf($_lang["duplicate_alias_found"], $docid->id, $alias), $newResourceRedirect); - } - } +try { + $saved = \EvolutionCMS\Services\DocumentSaveService::forManager()->save($_POST); +} catch (\EvolutionCMS\Services\DocumentSave\DocumentSaveDenied $denied) { + if (!$denied->restoreForm) { + $modx->webAlertAndQuit($denied->getMessage()); } - - // strip alias of special characters - elseif ($alias) { - $alias = $modx->stripAlias($alias); - $docid = \EvolutionCMS\Models\SiteContent::withTrashed()->select('id') - ->where('id', '<>', $id) - ->where('alias', $alias) - ->where('parent', $parent) - ->first(); - if (!is_null($docid)) { - if ($actionToTake == 'edit') { - $modx->getManagerApi()->saveFormValues($editResourceAction); - $modx->webAlertAndQuit(sprintf($_lang["duplicate_alias_found"], $docid->id, $alias), $editResourceRedirect); - } else { - $modx->getManagerApi()->saveFormValues($newResourceAction); - $modx->webAlertAndQuit(sprintf($_lang["duplicate_alias_found"], $docid->id, $alias), $newResourceRedirect); - } - } + if ($actionToTake == 'edit') { + $modx->getManagerApi()->saveFormValues($editResourceAction); + $modx->webAlertAndQuit($denied->getMessage(), $editResourceRedirect); } -} -elseif ($alias) { - $alias = $modx->stripAlias($alias); + $modx->getManagerApi()->saveFormValues($newResourceAction); + $modx->webAlertAndQuit($denied->getMessage(), $newResourceRedirect); } -// determine published status -$currentdate = $modx->timestamp((int)get_by_key($_SERVER, 'REQUEST_TIME', 0)); +$id = $saved->id; -if (empty ($pub_date)) { - $pub_date = 0; -} else { - $pub_date = $modx->toTimeStamp($pub_date); +// Set the item name for logger +$_SESSION['itemname'] = $saved->pagetitle; - if ($pub_date < $currentdate) { - $published = 1; - } - elseif ($pub_date > $currentdate) { - $published = 0; - } +if ($syncsite == 1) { + // empty cache + $modx->clearCache('document'); } -if (empty ($unpub_date)) { - $unpub_date = 0; +if (!$saved->isNew() && ($_POST['refresh_preview'] ?? '') == '1') { + $redirectUrl = EVO_SITE_URL . "index.php?id=$id&z=manprev"; } else { - $unpub_date = $modx->toTimeStamp($unpub_date); - if ($unpub_date < $currentdate) { - $published = 0; + if (!$saved->isNew() && $stay != '2') { + $modx->unlockElement(7, $id); } -} - -// get document groups for current user -$tmplvars =[]; -$docgrp = array_unique(\EvolutionCMS\Models\MemberGroup::query() - ->join('membergroup_access', 'membergroup_access.membergroup', '=', 'member_groups.user_group') - ->where('member_groups.member', $modx->getLoginUserID('mgr'))->pluck('documentgroup')->toArray()); - -// ensure that user has not made this document inaccessible to themselves -if($_SESSION['mgrRole'] != 1 && is_array($document_groups)) { - $document_group_list = implode(',', $document_groups); - $document_group_list = array_filter(explode(',', $document_group_list), 'is_numeric'); - if(!empty($document_group_list)) { - $count = \EvolutionCMS\Models\MembergroupAccess::query() - ->join('member_groups', 'membergroup_access.membergroup', '=', 'member_groups.user_group') - ->whereIn('membergroup_access.documentgroup', $document_group_list) - ->where('member_groups.member', $_SESSION['mgrInternalKey'])->count('member_groups.id'); - - if($count == 0) { - if ($actionToTake == 'edit') { - $modx->getManagerApi()->saveFormValues($editResourceAction); - $modx->webAlertAndQuit($_lang["resource_permissions_error"], $editResourceRedirect); - } else { - $modx->getManagerApi()->saveFormValues($newResourceAction); - $modx->webAlertAndQuit($_lang["resource_permissions_error"], $newResourceRedirect); - } + if ($stay != '') { + if ($type == "reference") { + // weblink + $a = ($stay == '2') ? "27&id=$id" : "72&pid=$parentId"; + } else { + // document + $a = ($stay == '2') ? "27&id=$id" : "4&pid=$parentId"; } - } -} - -$tvs = \EvolutionCMS\Models\SiteTmplvar::query()->distinct() - ->select('site_tmplvars.*', 'site_tmplvar_contentvalues.value') - ->join('site_tmplvar_templates', 'site_tmplvar_templates.tmplvarid', '=', 'site_tmplvars.id') - ->leftJoin('site_tmplvar_contentvalues', function ($join) use ($id) { - $join->on('site_tmplvar_contentvalues.tmplvarid', '=', 'site_tmplvars.id'); - $join->on('site_tmplvar_contentvalues.contentid', '=', \DB::raw($id)); - })->leftjoin('site_tmplvar_access', 'site_tmplvar_access.tmplvarid', '=', 'site_tmplvars.id') - ->where('site_tmplvar_templates.templateid', $template)->orderBy('site_tmplvars.rank'); -if($_SESSION['mgrRole']!= 1){ - $tvs = $tvs->leftJoin('document_groups', 'site_tmplvar_contentvalues.contentid', '=', 'document_groups.document'); - $tvs = $tvs->where(function ($query) { - $query->whereNull('site_tmplvar_access.documentgroup') - ->orWhereIn('document_groups.document_group', $_SESSION['mgrDocgroups']); - }); -} -$tvs = $tvs->get(); -foreach ($tvs->toArray() as $row) { - $tmplvar = ''; - switch ($row['type']) { - case 'url': - $tmplvar = $_POST["tv" . $row['id']]; - if ($_POST["tv" . $row['id'] . '_prefix'] != '--') { - $tmplvar = str_replace([ - "feed://", - "ftp://", - "http://", - "https://", - "mailto:" - ], "", $tmplvar); - $tmplvar = $_POST["tv" . $row['id'] . '_prefix'] . $tmplvar; - } - break; - case 'file': - $tmplvar = $_POST["tv" . $row['id']]; - break; - default: - $tmp = get_by_key($_POST, 'tv' . $row['id']); - if (is_array($tmp)) { - // handles checkboxes & multiple selects elements - $feature_insert = []; - foreach ($tmp as $featureValue => $feature_item) { - $feature_insert[count($feature_insert)] = $feature_item; - } - $tmplvar = implode("||", $feature_insert); - } else { - $tmplvar = $tmp; - } - break; - } - // save value if it was modified - if (!empty($tmplvar) && $tmplvar != $row['default_text']) { - $tmplvars[$row['id']] = [ - $row['id'], - $tmplvar - ]; + $redirectUrl = "index.php?a=" . $a . "&r=1&stay=" . (int)$stay; } else { - // Mark the variable for deletion - $tmplvars[$row['name']] = $row['id']; + $redirectUrl = "index.php?a=3&id=$id&r=1"; } -} - -// get the document, but only if it already exists -if ($actionToTake != "new") { - $existingDocument = \EvolutionCMS\Models\SiteContent::withTrashed()->find($id); - if (is_null($existingDocument)) { - $modx->webAlertAndQuit($_lang["error_no_results"]); + if (!$saved->isNew()) { + $redirectUrl .= $add_path; } - $existingDocument = $existingDocument->toArray(); -} - - - -// check to see if the user is allowed to save the document in the place he wants to save it in -if ($modx->getConfig('use_udperms') == 1) { - if (!isset($existingDocument) || $existingDocument['parent'] != $parent) { - $udperms = new EvolutionCMS\Legacy\Permissions(); - $udperms->user = $modx->getLoginUserID('mgr'); - $udperms->document = $parent; - $udperms->role = $_SESSION['mgrRole']; - - if (!$udperms->checkPermissions()) { - if ($actionToTake == 'edit') { - $modx->getManagerApi()->saveFormValues($editResourceAction); - $modx->webAlertAndQuit($_lang['access_permission_parent_denied'], $editResourceRedirect); - } else { - $modx->getManagerApi()->saveFormValues($newResourceAction); - $modx->webAlertAndQuit($_lang['access_permission_parent_denied'], $newResourceRedirect); - } - } - } -} - -$resourceArray = [ - "introtext" => $introtext , - "content" => $content , - "pagetitle" => $pagetitle , - "longtitle" => $longtitle , - "type" => $type , - "description" => $description , - "alias" => $alias , - "link_attributes" => $link_attributes , - "isfolder" => $isfolder , - "richtext" => $richtext , - "published" => $published , - "parent" => $parent , - "template" => $template , - "menuindex" => $menuindex , - "searchable" => $searchable , - "cacheable" => $cacheable , - "editedby" => $modx->getLoginUserID('mgr') , - "editedon" => $currentdate , - "pub_date" => $pub_date , - "unpub_date" => $unpub_date , - "contentType" => $contentType , - "content_dispo" => $contentdispo , - "hide_from_tree" => $hide_from_tree , - "menutitle" => $menutitle , - "hidemenu" => $hidemenu , - "alias_visible" => $aliasvisible -]; - -switch ($actionToTake) { - case 'new' : - $resourceArray['createdby'] = $modx->getLoginUserID('mgr'); - $resourceArray['createdon'] = $currentdate; - // invoke OnBeforeDocFormSave event - switch($modx->config['docid_incrmnt_method']) - { - case '1': - $id = \EvolutionCMS\Models\SiteContent::withTrashed() - ->leftJoin('site_content as t1', function ($join) { - $join->on(\DB::raw(evo()->getDatabase()->getFullTableName('site_content').'.id +1'), '=', 't1.id'); - }) - ->whereNull('t1.id')->min('site_content.id'); - $id++; - - break; - case '2': - $id = \EvolutionCMS\Models\SiteContent::max('id'); - $id++; - break; - - default: - $id = ''; - } - - $modx->invokeEvent("OnBeforeDocFormSave", [ - "mode" => "new", - "id" => $id - ]); - - $parentDeleted = $parentId > 0 && empty(\EvolutionCMS\Models\SiteContent::find($parentId)); - if ($parentDeleted) { - $resourceArray['deleted'] = 1; - } - // deny publishing if not permitted - if (!$modx->hasPermission('publish_document')) { - $pub_date = 0; - $unpub_date = 0; - $published = 0; - } - - $publishedon = ($published ? $currentdate : 0); - $publishedby = ($published ? $modx->getLoginUserID('mgr') : 0); - - if ((!empty($pub_date))&&($published)){ - $publishedon=$pub_date; - } - - - $resourceArray['pub_date'] = $pub_date; - $resourceArray['publishedon'] = $publishedon; - $resourceArray['publishedby'] = $publishedby; - $resourceArray['unpub_date'] = $unpub_date; - - if ($id != '') - $resourceArray["id"] = $id; - - $key = \EvolutionCMS\Models\SiteContent::withTrashed()->create($resourceArray)->getKey(); - - $tvChanges = []; - foreach ($tmplvars as $field => $value) { - if (is_array($value)) { - $tvId = $value[0]; - $tvVal = $value[1]; - \EvolutionCMS\Models\SiteTmplvarContentvalue::query()->create(['tmplvarid' => $tvId, 'contentid' => $key, 'value' => $tvVal]); - } - } - - - // document access permissions - if ($modx->getConfig('use_udperms') && $parent != 0) { - $groupsParent = \EvolutionCMS\Models\DocumentGroup::select('document_group', 'document') - ->where('document', $parent)->pluck('document_group')->toArray(); - } else { - $groupsParent = []; - } - if ($modx->getConfig('use_udperms') == 1 && $modx->hasAnyPermissions(['manage_groups', 'manage_document_permissions']) && is_array($document_groups)) { - $new_groups = []; - $groupsToInsert = []; - foreach ($document_groups as $value_pair) { - // first, split the pair (this is a new document, so ignore the second value - [$group] = explode(',', $value_pair); // @see actions/mutate_content.dynamic.php @ line 1138 (permissions list) - $group = (int)$group; - if ($modx->hasPermission('manage_groups')) { - $new_groups[] = ['document_group' => $group, 'document' => $key]; - $groupsToInsert[] = $group; - continue; - } - if ($modx->hasPermission('manage_document_permissions')) { - if (in_array($group, $docgrp)) { - $new_groups[] = ['document_group' => $group, 'document' => $key]; - $groupsToInsert[] = $group; - } - } - } - if ($modx->hasPermission('manage_document_permissions')) { - foreach ($groupsParent as $group) { - if (!in_array($group, $docgrp)) { - $new_groups[] = ['document_group' => $group, 'document' => $key]; - $groupsToInsert[] = $group; - } - } - } - if (!$modx->hasPermission('manage_groups')) { - if (!array_intersect($groupsToInsert, $docgrp)) { - foreach ($groupsParent as $group){ - $new_groups[] = ['document_group' => $group, 'document' => $key]; - } - } - } - if (!empty($new_groups)) { - \EvolutionCMS\Models\DocumentGroup::query()->insertOrIgnore($new_groups); - } - } else { - if(!($modx->hasAnyPermissions(['manage_groups', 'manage_document_permissions']))) { - // inherit document access permissions - foreach ($groupsParent as $group){ - \EvolutionCMS\Models\DocumentGroup::insert(['document_group'=>$group, 'document'=>$key]); - } - } - } - - // update parent folder status - if ($resourceArray['parent'] != 0) { - $fields = ['isfolder' => 1]; - \EvolutionCMS\Models\SiteContent::withTrashed()->where('id',$resourceArray['parent'])->update(['isfolder'=>1]); - } - - // invoke OnDocFormSave event - $modx->invokeEvent("OnDocFormSave", [ - "mode" => "new", - "id" => $key - ]); - - // secure web documents - flag as private - include EVO_MANAGER_PATH . "includes/secure_web_documents.inc.php"; - secureWebDocument($key); - secureMgrDocument($key); - - // Set the item name for logger - $_SESSION['itemname'] = $no_esc_pagetitle; - - if ($syncsite == 1) { - // empty cache - $modx->clearCache('document'); - } - - // redirect/stay options - if ($_POST['stay'] != '') { - if ($type == "reference") { - $a = ($_POST['stay'] == '2') ? "27&id=$key" : "72&pid=$parentId"; - } else { - $a = ($_POST['stay'] == '2') ? "27&id=$key" : "4&pid=$parentId"; - } - $redirectUrl = "index.php?a=" . $a . "&r=1&stay=" . (int)$_POST['stay']; - } else { - $redirectUrl = "index.php?a=3&id=$key&r=1"; - } - evo()->sendRedirect($redirectUrl, 0, headers_sent() ? 'REDIRECT_SCRIPT' : ''); - break; - case 'edit' : - // get the document's current parent - $oldparent = $existingDocument['parent']; - $doctype = $existingDocument['type']; - - if ($id == $modx->getConfig('site_start') && $published == 0) { - $modx->getManagerApi()->saveFormValues(27); - $modx->webAlertAndQuit("Document is linked to site_start variable and cannot be unpublished!"); - } - $today = $modx->timestamp((int)get_by_key($_SERVER, 'REQUEST_TIME', 0)); - if ($id == $modx->getConfig('site_start') && ($pub_date > $today || $unpub_date != "0")) { - $modx->getManagerApi()->saveFormValues(27); - $modx->webAlertAndQuit("Document is linked to site_start variable and cannot have publish or unpublish dates set!"); - } - if ($parent == $id) { - $modx->getManagerApi()->saveFormValues(27); - $modx->webAlertAndQuit("Document can not be it's own parent!"); - } - - $parents = $modx->getParentIds($parent); - if (in_array($id, $parents)) { - $modx->webAlertAndQuit("Document descendant can not be it's parent!"); - } - - // check to see document is a folder - $child = \EvolutionCMS\Models\SiteContent::withTrashed()->select('id')->where('parent', $id)->first(); - if (!is_null($child)) { - $resourceArray['isfolder'] = 1; - } - - // set publishedon and publishedby - $was_published = $existingDocument['published']; - - // keep original publish state, if change is not permitted - if (!$modx->hasPermission('publish_document')) { - $published = $was_published; - $pub_date = 'pub_date'; - $unpub_date = 'unpub_date'; - } - - // if it was changed from unpublished to published - if (!$was_published && $published) { - $publishedon = $currentdate; - $publishedby = $modx->getLoginUserID('mgr'); - }elseif ((!empty($pub_date)&& $pub_date<=$currentdate && $published)) { - $publishedon = $pub_date; - $publishedby = $modx->getLoginUserID('mgr'); - }elseif ($was_published && !$published) { - $publishedon = 0; - $publishedby = 0; - } else { - $publishedon = $existingDocument['publishedon']; - $publishedby = $existingDocument['publishedby']; - } - - $resourceArray['pub_date'] = $pub_date; - $resourceArray['publishedon'] = $publishedon; - $resourceArray['publishedby'] = $publishedby; - - // invoke OnBeforeDocFormSave event - $modx->invokeEvent("OnBeforeDocFormSave", [ - "mode" => "upd", - "id" => $id - ]); - $parentDeleted = $parentId > 0 && empty(\EvolutionCMS\Models\SiteContent::find($parentId)); - if ($parentDeleted) { - $resourceArray['deleted'] = 1; - } - $resource = \EvolutionCMS\Models\SiteContent::withTrashed()->find($id); - foreach($resourceArray as $key=>$value){ - $resource->{$key} = $value; - } - $resource->save(); - - // update template variables - $tvs = \EvolutionCMS\Models\SiteTmplvarContentvalue::select('id', 'tmplvarid')->where('contentid', $id)->get(); - $tvIds = []; - foreach ($tvs as $tv) { - $tvIds[$tv->tmplvarid] = $tv->id; - } - $tvDeletions = []; - $tvChanges = []; - $tvAdded = []; - - foreach ($tmplvars as $field => $value) { - - if (!is_array($value)) { - if (isset($tvIds[$value])) $tvDeletions[] = $tvIds[$value]; - } else { - $tvId = $value[0]; - $tvVal = $value[1]; - if (isset($tvIds[$tvId])) { - \EvolutionCMS\Models\SiteTmplvarContentvalue::query()->find($tvIds[$tvId])->update(['tmplvarid' => $tvId, 'contentid' => $id, 'value' => $tvVal]); - } else { - \EvolutionCMS\Models\SiteTmplvarContentvalue::query()->create(['tmplvarid' => $tvId, 'contentid' => $id, 'value' => $tvVal]); - } - } - } - - if (!empty($tvDeletions)) { - \EvolutionCMS\Models\SiteTmplvarContentvalue::query()->whereIn('id', $tvDeletions)->delete(); - } - - // set document permissions - if ($modx->getConfig('use_udperms') == 1 && $modx->hasAnyPermissions(['manage_groups', 'manage_document_permissions']) && is_array($document_groups)) { - $new_groups = []; - // process the new input - foreach ($document_groups as $value_pair) { - [$group, $link_id] = explode(',', $value_pair); // @see actions/mutate_content.dynamic.php @ line 1138 (permissions list) - if (in_array($group, $docgrp) || $modx->hasPermission('manage_groups')) { - $new_groups[$group] = $link_id; - } - } - - // grab the current set of permissions on this document the user can access - $documentGroups = \EvolutionCMS\Models\DocumentGroup::select('id','document_group') - ->where('document', $id)->get(); - - $old_groups = []; - foreach ($documentGroups as $documentGroup) { - if (in_array($documentGroup->document_group, $docgrp) || $modx->hasPermission('manage_groups')) { - $old_groups[$documentGroup->document_group] = $documentGroup->id; - } - } - // update the permissions in the database - $insertions = $deletions = []; - foreach ($new_groups as $group => $link_id) { - if (in_array($group, $docgrp) || $modx->hasPermission('manage_groups')) { - if (array_key_exists($group, $old_groups)) { - unset($old_groups[$group]); - continue; - } elseif ($link_id == 'new') { - $insertions[] = ['document_group' => (int) $group, 'document' => $id]; - } - } - } - if (!empty($insertions)) { - \EvolutionCMS\Models\DocumentGroup::query()->insert($insertions); - } - if (!$modx->hasPermission('manage_groups')) { - $remainingGroups = \EvolutionCMS\Models\DocumentGroup::select('document_groups.document_group')->whereNotIn('id', - $old_groups)->where('document_groups.document', $id)->pluck('document_group')->toArray(); - if (!empty($docgrp) && !array_intersect($docgrp, $remainingGroups)) { - $modx->webAlertAndQuit($_lang["resource_permissions_error"], "index.php?a=27&id={$id}"); - } - } - if (!empty($old_groups)) { - \EvolutionCMS\Models\DocumentGroup::query()->whereIn('id', $old_groups)->delete(); - } - // necessary to remove all permissions as document is public - if ((isset($_POST['chkalldocs']) && $_POST['chkalldocs'] == 'on')) { - \EvolutionCMS\Models\DocumentGroup::query()->where('document', $id)->delete(); - } - } - - // do the parent stuff - if ($resourceArray['parent'] != 0) { - $parent = \EvolutionCMS\Models\SiteContent::withTrashed()->find($_REQUEST['parent']); - $parent->isfolder = 1; - $parent->save(); - } - - // finished moving the document, now check to see if the old_parent should no longer be a folder - $countChildOldParent = \EvolutionCMS\Models\SiteContent::withTrashed()->where('parent', $oldparent)->count(); - - if ($countChildOldParent == 0) { - $oldParent = \EvolutionCMS\Models\SiteContent::withTrashed()->find($oldparent); - $oldParent->isfolder = 0; - $oldParent->save(); - } - - - // invoke OnDocFormSave event - $modx->invokeEvent("OnDocFormSave", [ - "mode" => "upd", - "id" => $id - ]); - - // secure web documents - flag as private - include EVO_MANAGER_PATH . "includes/secure_web_documents.inc.php"; - secureWebDocument($id); - secureMgrDocument($id); - - - // Set the item name for logger - $_SESSION['itemname'] = $no_esc_pagetitle; - - if ($syncsite == 1) { - // empty cache - $modx->clearCache('document'); - } - - if ($_POST['refresh_preview'] == '1') - $redirectUrl = EVO_SITE_URL . "index.php?id=$id&z=manprev"; - else { - if ($_POST['stay'] != '2' && $id > 0) { - $modx->unlockElement(7, $id); - } - if ($_POST['stay'] != '') { - $id = $_REQUEST['id']; - if ($type == "reference") { - // weblink - $a = ($_POST['stay'] == '2') ? "27&id=$id" : "72&pid=$parentId"; - } else { - // document - $a = ($_POST['stay'] == '2') ? "27&id=$id" : "4&pid=$parentId"; - } - $redirectUrl = "index.php?a=" . $a . "&r=1&stay=" . (int)$_POST['stay'] . $add_path; - } else { - $redirectUrl = "index.php?a=3&id=$id&r=1" . $add_path; - } - } - evo()->sendRedirect($redirectUrl, 0, headers_sent() ? 'REDIRECT_SCRIPT' : ''); - break; - default : - $modx->webAlertAndQuit("No operation set in request."); } +evo()->sendRedirect($redirectUrl, 0, headers_sent() ? 'REDIRECT_SCRIPT' : ''); diff --git a/manager/processors/web_access_groups.processor.php b/manager/processors/web_access_groups.processor.php index a219df26e1..7d269a0f7c 100755 --- a/manager/processors/web_access_groups.processor.php +++ b/manager/processors/web_access_groups.processor.php @@ -111,12 +111,7 @@ // secure web documents - flag as private if ($updategroupaccess == true) { - include EVO_MANAGER_PATH . "includes/secure_web_documents.inc.php"; - if ($context) { - secureWebDocument(); - } else { - secureMgrDocument(); - } + \EvolutionCMS\Support\DocumentPrivacy::refreshAll($context ? \EvolutionCMS\Support\DocumentPrivacy::WEB : \EvolutionCMS\Support\DocumentPrivacy::MANAGER); // Update the private group column $columnName = $context ? 'private_webgroup' : 'private_memgroup'; $resp = \EvolutionCMS\Models\DocumentgroupName::query()->select('documentgroup_names.id', From 29dae333dc4b51ab1dfedba97f8b68fa0dc5b805 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 21 Sep 2026 18:27:20 +0200 Subject: [PATCH 2/5] ref(manager): document save speedup by AJAX call eliminating need for second call --- core/lang/az/global.php | 1 + core/lang/be/global.php | 1 + core/lang/bg/global.php | 1 + core/lang/cs/global.php | 1 + core/lang/da/global.php | 1 + core/lang/de/global.php | 1 + core/lang/en/global.php | 1 + core/lang/es/global.php | 1 + core/lang/fa/global.php | 1 + core/lang/fi/global.php | 1 + core/lang/fr/global.php | 1 + core/lang/he/global.php | 1 + core/lang/it/global.php | 1 + core/lang/ja/global.php | 1 + core/lang/nl/global.php | 1 + core/lang/nn/global.php | 1 + core/lang/pl/global.php | 1 + core/lang/pt/global.php | 1 + core/lang/sk/global.php | 1 + core/lang/sv/global.php | 1 + core/lang/uk/global.php | 1 + core/lang/zh/global.php | 1 + .../DocumentSave/DocumentSaveResult.php | 1 + core/src/Services/DocumentSaveService.php | 13 +- .../src/Support/DocumentSave/SaveResponse.php | 55 ++++++++ .../Manager/ReferenceTypeSwitchFlowTest.php | 4 +- .../Unit/Services/DocumentSaveServiceTest.php | 2 + .../Support/DocumentSave/SaveResponseTest.php | 67 ++++++++++ core/vendor/composer/autoload_classmap.php | 1 + core/vendor/composer/autoload_static.php | 1 + manager/actions/mutate_content.dynamic.php | 58 ++++++++ manager/media/script/document-save-helper.js | 125 ++++++++++++++++++ manager/media/script/main.js | 2 +- .../script/tests/document-save-helper.test.js | 100 ++++++++++++++ manager/media/style/default/css/custom.css | 16 ++- manager/processors/save_content.processor.php | 53 +++++--- 36 files changed, 490 insertions(+), 30 deletions(-) create mode 100644 core/src/Support/DocumentSave/SaveResponse.php create mode 100644 core/tests/Unit/Support/DocumentSave/SaveResponseTest.php create mode 100644 manager/media/script/document-save-helper.js create mode 100644 manager/media/script/tests/document-save-helper.test.js 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/Services/DocumentSave/DocumentSaveResult.php b/core/src/Services/DocumentSave/DocumentSaveResult.php index f50e417c4c..048409af43 100644 --- a/core/src/Services/DocumentSave/DocumentSaveResult.php +++ b/core/src/Services/DocumentSave/DocumentSaveResult.php @@ -17,6 +17,7 @@ public function __construct( public readonly int $parent, public readonly string $pagetitle, public readonly string $alias, + public readonly int $editedon = 0, ) { } diff --git a/core/src/Services/DocumentSaveService.php b/core/src/Services/DocumentSaveService.php index a90883dd18..c4696ae2c9 100644 --- a/core/src/Services/DocumentSaveService.php +++ b/core/src/Services/DocumentSaveService.php @@ -122,8 +122,11 @@ public function save(array $input): DocumentSaveResult // 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()]); - $id = $this->transaction(function () use ($fields, $tvs, $tvValues, $parent, $parentRow, $groupPairs, $usePermissions) { - $id = (int) SiteContent::withTrashed()->create($fields)->getKey(); + $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); @@ -145,11 +148,13 @@ public function save(array $input): DocumentSaveResult $ctx->fire('OnBeforeDocFormSave', ['mode' => 'upd', 'id' => $id]); - $this->transaction(function () use ($existing, $id, $fields, $tvs, $tvValues, $parent, $oldParent, $parentRow, $groupPairs, $makePublic, $usePermissions) { + $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); @@ -170,7 +175,7 @@ public function save(array $input): DocumentSaveResult // after the event, a plugin may have changed the groups DocumentPrivacy::refresh($id); - return new DocumentSaveResult($id, $mode, $type, $parent, $pagetitle, $alias); + return new DocumentSaveResult($id, $mode, $type, $parent, $pagetitle, $alias, $editedon); } /** 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/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/Services/DocumentSaveServiceTest.php b/core/tests/Unit/Services/DocumentSaveServiceTest.php index 0babfdce84..3d54360d1b 100644 --- a/core/tests/Unit/Services/DocumentSaveServiceTest.php +++ b/core/tests/Unit/Services/DocumentSaveServiceTest.php @@ -94,6 +94,8 @@ function dbSnapshot(): array ->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); 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/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index e90269da6a..421dfe6a32 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1408,6 +1408,7 @@ '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', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index f9a88eea13..fa90d2f3bd 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2085,6 +2085,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 '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', 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 } +