| @copyright 2007-2026 (c) AVE.cms | @link https://ave-cms.ru | @version 3.3 */ namespace App\Adminx\Documents; defined('BASEPATH') || die('Direct access to this location is not allowed.'); use App\Common\AdminAssets; use App\Common\ApiTokenRepository; use App\Common\AuditLog; use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\ErrorReport; use App\Common\Permission; use App\Adminx\Support\CodeEditor; use App\Adminx\Rubrics\AdminView; use App\Content\Documents\DocumentHookException; use App\Content\Documents\ContentCacheInvalidator; use App\Content\Documents\DocumentCreationPresetRepository; use App\Content\Documents\DocumentMediaDraft; use App\Content\Documents\DocumentMediaFinalizer; use App\Content\Documents\DocumentSaveEvents; use App\Content\Documents\DocumentSaveRejected; use App\Content\Documents\DocumentSnapshotRepository; use App\Content\Documents\DocumentSnapshotStore; use App\Content\Documents\FieldTemplateManifest; use App\Content\Documents\RubricSchemaBuilder; use App\Frontend\DocumentRevisionPreview; use App\Helpers\Request; use DB; class Controller extends BaseController { public function index(array $params = array()) { AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); CodeEditor::useCodeMirror('application/x-httpd-php'); $filters = array( 'q' => Request::getStr('q', ''), 'field' => Request::getStr('field', ''), 'rubric_id' => Request::getInt('rubric_id', 0), 'state' => Request::getStr('state', ''), 'page' => max(1, Request::getInt('page', 1)), 'per_page' => Request::getInt('per_page', 25), ); //-- Поля поиска зависят от выбранной рубрики; сбрасываем поле, если // оно не входит в её набор. $searchableFields = Model::searchableFields($filters['rubric_id']); $filters['field'] = Model::normalizeSearchField($filters['field'], $filters['rubric_id']); $result = Model::items($filters); $adminView = AdminView::forDocuments($filters['rubric_id'], $result['items']); $result['items'] = $adminView['documents']; $stateLabels = array( '' => 'Рабочие', 'active' => 'Опубликованные', 'draft' => 'Черновики', 'deleted' => 'Удалённые', ); return $this->render('@documents/index.twig', array( 'documents' => $result['items'], 'pagination' => $result['pagination'], 'stats' => Model::stats(), 'rubrics' => $this->creationRubrics(), 'searchable_fields' => $searchableFields, 'filters' => $filters, 'filter_state_label' => isset($stateLabels[$filters['state']]) ? $stateLabels[$filters['state']] : $stateLabels[''], 'admin_view' => $adminView, 'table_name' => Model::documentsTable(), 'can_manage' => Permission::check('manage_documents'), 'open_create' => Request::getBool('create', false), )); } public function create(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); CodeEditor::useRichEditor(); $rubricId = Request::getInt('rubric_id', 0); if ($rubricId <= 0 || !Model::rubric($rubricId)) { $this->redirect($this->base() . '/documents?create=1'); return ''; } $document = Model::blank($rubricId); $presetRepository = new DocumentCreationPresetRepository(); $preset = null; $presetId = Request::getInt('preset', 0); if ($presetId > 0) { $preset = $presetRepository->find($presetId); if (!$preset || (int) $preset['rubric_id'] !== $rubricId) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Пресет документа не найден'), 404); } $document = $presetRepository->applyToDocument($document, $preset); } $mediaDraftToken = DocumentMediaDraft::issue(Auth::id(), 0); return $this->render('@documents/edit.twig', array( 'document' => $document, 'field_groups' => Model::fieldsForRubric( (int) $document['rubric_id'], 0, $mediaDraftToken, $preset ? $presetRepository->fieldDefaults($preset) : array() ), 'media_draft_token' => $mediaDraftToken, 'rubrics' => Model::rubrics(), 'templates' => Model::rubricTemplates((int) $document['rubric_id']), 'navigation_items' => Model::navigationItems(), 'authors' => Model::authors(), 'is_new' => true, 'creation_preset' => $preset, 'can_manage' => true, 'actor_id' => Auth::id(), )); } public function views(array $params = array()) { AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); $days = Request::getInt('days', 30); return $this->render('@documents/views.twig', array( 'view_stats' => Model::viewStats($days), 'can_manage' => Permission::check('manage_documents'), )); } public function redirects(array $params = array()) { AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/redirects.js', 50); $filters = array( 'q' => Request::getStr('q', ''), 'header' => Request::getInt('header', 0), 'page' => max(1, Request::getInt('page', 1)), ); $result = Model::allAliasHistory($filters); return $this->render('@documents/redirects.twig', array( 'redirects' => $result['items'], 'pagination' => $result['pagination'], 'stats' => Model::aliasHistoryStats(), 'filters' => $filters, 'can_manage' => Permission::check('manage_documents'), )); } public function apiTokens(array $params = array()) { if (!Permission::check('manage_document_api')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); return $this->render('@documents/api.twig', array('tokens' => ApiTokenRepository::all(), 'can_manage_api' => true)); } public function issueApiToken(array $params = array()) { if (($err = $this->csrfGuard()) !== null) { return $err; } if (!Permission::check('manage_document_api')) { return $this->error('Недостаточно прав', array(), 403); } try { $scopes = Request::post('scopes', array()); $scopes = is_array($scopes) ? $scopes : array(); $result = ApiTokenRepository::issue(Auth::id(), Request::postStr('name', ''), $scopes, Request::postStr('expires_at', '')); AuditLog::record('document.api_token_created', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$result['id'],'meta'=>array('scopes'=>$result['scopes']))); return $this->success('API-токен создан', array('data' => $result)); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } } public function revokeApiToken(array $params = array()) { if (($err = $this->csrfGuard()) !== null) { return $err; } if (!Permission::check('manage_document_api')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!ApiTokenRepository::revoke($id)) { return $this->error('Токен не найден или уже отозван', array(), 404); } AuditLog::record('document.api_token_revoked', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$id)); return $this->success('API-токен отозван'); } public function clearViews(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $count = Model::clearViewStats(); return $this->success('Подневная статистика очищена', array('data' => array('count' => $count))); } public function edit(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); CodeEditor::useRichEditor(); $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); if (!$item) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Документ не найден'), 404); } $mediaDraftToken = DocumentMediaDraft::issue(Auth::id(), (int) $item['Id']); return $this->render('@documents/edit.twig', array( 'document' => $item, 'field_groups' => Model::fieldsForDocument((int) $item['Id'], $mediaDraftToken), 'media_draft_token' => $mediaDraftToken, 'rubrics' => Model::rubrics(), 'templates' => Model::rubricTemplates((int) $item['rubric_id']), 'navigation_items' => Model::navigationItems(), 'authors' => Model::authors(), 'is_new' => false, 'is_homepage' => (int) $item['Id'] === 1, 'can_manage' => true, 'quick_edit' => Request::getBool('quick_edit', false), 'actor_id' => Auth::id(), )); } public function show(array $params = array()) { $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); if (!$item) { return $this->error('Документ не найден', array(), 404); } return $this->success('', array('data' => $item)); } public function aliasCheck(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } $alias = trim(Request::getStr('alias', '')); $kind = Request::getStr('kind', 'alias') === 'short' ? 'short' : 'alias'; $id = Request::getInt('id', 0); if ($alias === '') { $message = ($kind === 'short' ? 'Короткий alias' : 'Alias') . ' можно оставить пустым'; return $this->success('Alias не указан', array('data' => array('ok' => true, 'state' => 'empty', 'message' => $message))); } $pattern = $kind === 'short' ? '/^[a-zA-Z0-9_\-]+$/' : '/^[a-zA-Z0-9_\\-\\/\\.]+$/'; if (!preg_match($pattern, $alias)) { $message = $kind === 'short' ? 'Допустимы латиница, цифры, дефис и подчёркивание' : 'Допустимы латиница, цифры, точка, дефис, подчёркивание и слэш'; return $this->success('Некорректный alias', array('data' => array('ok' => false, 'state' => 'error', 'message' => $message))); } $conflict = Model::aliasConflict($alias, $id); if ($conflict) { $label = isset($conflict['label']) ? (string) $conflict['label'] : 'другой раздел'; return $this->success('Alias занят', array('data' => array('ok' => false, 'state' => 'error', 'message' => 'URL уже занят: ' . $label))); } return $this->success('Alias свободен', array('data' => array('ok' => true, 'state' => 'ok', 'message' => 'Alias свободен'))); } public function slug(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } $title = trim(Request::postStr('title', '')); $id = Request::postInt('id', 0); $parent = Request::postInt('parent', 0); $rubricId = Request::postInt('rubric_id', 0); $published = Request::postStr('published', ''); $publishedAt = $published !== '' ? strtotime($published) : 0; if ($title === '') { return $this->error('Укажите название документа', array(), 422); } return $this->success('', array('data' => array( 'ok' => true, 'alias' => Model::generateAlias($title, $id, $parent, $rubricId, $publishedAt), ))); } public function shortAlias(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } $id = Request::postInt('id', 0); return $this->success('', array('data' => array('ok' => true, 'alias' => Model::generateShortAlias($id)))); } public function documentPicker(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } return $this->success('', array( 'data' => array( 'items' => Model::documentPicker( Request::getStr('q', ''), Request::getStr('rubric_id', ''), Request::getInt('limit', 20) ), ), )); } public function termSuggestions(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } $kind = Request::getStr('kind', 'tags'); if (!in_array($kind, array('keywords', 'tags'), true)) { return $this->error('Неизвестный тип значений', array(), 422); } return $this->success('', array('data' => array( 'items' => Model::termSuggestions( $kind, Request::getStr('q', ''), Request::getInt('rubric_id', 0), Request::getInt('limit', 12) ), ))); } public function bulk(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $postedIds = Request::post('ids', array()); $ids = is_array($postedIds) ? array_values(array_unique(array_map('intval', $postedIds))) : array(); $ids = array_values(array_filter($ids, function ($id) { return $id > 0; })); if (empty($ids) || count($ids) > 200) { return $this->error('Выберите от 1 до 200 документов', array(), 422); } $action = Request::postStr('action', ''); if (!in_array($action, array('publish', 'unpublish', 'delete', 'restore', 'purge'), true)) { return $this->error('Неизвестное пакетное действие', array(), 422); } $done = 0; $errors = array(); foreach ($ids as $id) { try { $doc = Model::one($id); if (!$doc) { continue; } if ($action === 'publish' && empty($doc['document_status'])) { Model::toggleStatus($id); } elseif ($action === 'unpublish' && !empty($doc['document_status'])) { Model::toggleStatus($id); } elseif ($action === 'delete') { Model::delete($id); } elseif ($action === 'restore') { Model::restore($id); } elseif ($action === 'purge') { Model::purge($id); } $done++; } catch (\Throwable $e) { $errors[] = '#' . $id . ': ' . $e->getMessage(); } } return $this->success('Пакетное действие выполнено', array('data' => array('done' => $done, 'errors' => $errors))); } public function store(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $input = Model::prepareAliasInput(Request::postAll(), 0); $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0); $input['fields'] = $fieldValues; try { DocumentSaveEvents::before('create', 'adminx', 0, $rubricId, Auth::id(), $input, $fieldValues); DocumentHookRunner::before($rubricId, $input, $fieldValues, 0, Auth::id(), true, 'adminx'); } catch (DocumentSaveRejected $e) { return $this->error($e->getMessage(), $e->errors(), 422); } catch (DocumentHookException $e) { return $this->error('Код рубрики остановил создание документа', array('rubric_code_start' => $e->getMessage()), 422); } $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0); $input['fields'] = $fieldValues; $input = Model::prepareAliasInput($input, 0); $errors = $this->validate($input, 0); $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, 0)); if (!empty($errors)) { return $this->error('Проверьте поля документа', $errors, 422); } $id = 0; $mediaFinalization = null; $previousDatabaseExceptionMode = DB::$throw_exception_on_error; DB::$throw_exception_on_error = true; DB::startTransaction(); try { $id = Model::save(0, $input, Auth::id()); $mediaFinalization = DocumentMediaFinalizer::finalize( Request::postStr('media_draft_token', ''), Auth::id(), $id, $rubricId, $fieldValues ); $afterHook = DocumentHookRunner::after($rubricId, $input, $fieldValues, $id, Auth::id(), true, 'adminx'); if ($afterHook['document_changed'] || $afterHook['save_requested']) { Model::save($id, $input, Auth::id()); } Model::saveFields($id, $fieldValues); DB::commit(); $mediaFinalization->commit(); } catch (DocumentHookException $e) { DB::rollback(); if ($mediaFinalization) { $mediaFinalization->rollback(); } if ($id > 0) { ContentCacheInvalidator::document($id, false); } if (ErrorReport::isDatabase($e)) { return $this->error(ErrorReport::publicMessage( 'Код рубрики после сохранения не выполнен из-за ошибки базы данных', $e, 'DOCREATE', array('rubric_id' => $rubricId, 'document_id' => $id) ), array(), 500); } return $this->error( 'Код рубрики после сохранения остановил создание документа', array('rubric_code_end' => $e->getMessage()), 422 ); } catch (\Throwable $e) { DB::rollback(); if ($mediaFinalization) { $mediaFinalization->rollback(); } if ($id > 0) { ContentCacheInvalidator::document($id, false); } return $this->error(ErrorReport::publicMessage( 'Не удалось создать документ', $e, 'DOCREATE', array('rubric_id' => $rubricId, 'document_id' => $id) ), array(), 500); } finally { DB::$throw_exception_on_error = $previousDatabaseExceptionMode; } $snapshotError = ContentCacheInvalidator::consumeError($id); $snapshot = (new DocumentSnapshotRepository())->find($id); DocumentSaveEvents::after('create', 'adminx', $id, $rubricId, Auth::id(), $input, $fieldValues, array(), is_array($snapshot) ? $snapshot : array()); $nextMediaDraftToken = DocumentMediaDraft::issue(Auth::id(), $id); return $this->success('Документ создан', array('data' => array( 'id' => $id, 'document_version' => Model::documentVersion($id), 'snapshot_warning' => $snapshotError, 'media_draft_token' => $nextMediaDraftToken, ), 'redirect' => $this->base() . '/documents/' . $id . '/edit')); } public function previewPayload(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $id = Request::postInt('id', 0); $document = $id > 0 ? Model::one($id) : null; if ($id > 0 && !$document) { return $this->error('Документ не найден', array(), 404); } $input = Model::prepareAliasInput(Request::postAll(), $id); $rubricId = $document ? (int) $document['rubric_id'] : (isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0); $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, $id); $input['fields'] = $fieldValues; $errors = $this->validate($input, $id); $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, $id)); return $this->success('Данные сформированы без сохранения', array('data' => array( 'payload' => Model::previewPayload($id, $input, $fieldValues, Auth::id()), 'validation_errors' => $errors, ))); } public function update(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $id = isset($params['id']) ? (int) $params['id'] : 0; $doc = Model::one($id); if (!$doc) { return $this->error('Документ не найден', array(), 404); } $input = Request::postAll(); $expectedVersion = isset($input['document_version']) ? (int) $input['document_version'] : 0; if ($expectedVersion <= 0 || $expectedVersion !== (int) $doc['document_version']) { return $this->editConflict($id, (int) $doc['document_version']); } $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id); $input['fields'] = $fieldValues; try { DocumentSaveEvents::before('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc); DocumentHookRunner::before((int) $doc['rubric_id'], $input, $fieldValues, $id, Auth::id(), false, 'adminx'); } catch (DocumentSaveRejected $e) { return $this->error($e->getMessage(), $e->errors(), 422); } catch (DocumentHookException $e) { return $this->error('Код рубрики остановил сохранение документа', array('rubric_code_start' => $e->getMessage()), 422); } $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id); $input['fields'] = $fieldValues; $errors = $this->validate($input, $id); $errors = array_merge($errors, Model::validateFieldValues((int) $doc['rubric_id'], $fieldValues, $id)); if (!empty($errors)) { return $this->error('Проверьте поля документа', $errors, 422); } $mediaFinalization = null; $previousDatabaseExceptionMode = DB::$throw_exception_on_error; DB::$throw_exception_on_error = true; DB::startTransaction(); try { Model::assertEditVersion($id, $expectedVersion); Revisions::capture($id, Auth::id()); Model::save($id, $input, Auth::id()); $mediaFinalization = DocumentMediaFinalizer::finalize( Request::postStr('media_draft_token', ''), Auth::id(), $id, (int) $doc['rubric_id'], $fieldValues ); $afterHook = DocumentHookRunner::after((int) $doc['rubric_id'], $input, $fieldValues, $id, Auth::id(), false, 'adminx'); if ($afterHook['document_changed'] || $afterHook['save_requested']) { Model::save($id, $input, Auth::id()); } Model::saveFields($id, $fieldValues); DB::commit(); $mediaFinalization->commit(); } catch (EditConflict $e) { DB::rollback(); if ($mediaFinalization) { $mediaFinalization->rollback(); } return $this->editConflict($id, $e->currentVersion()); } catch (DocumentHookException $e) { DB::rollback(); if ($mediaFinalization) { $mediaFinalization->rollback(); } ContentCacheInvalidator::document($id, false); if (ErrorReport::isDatabase($e)) { return $this->error(ErrorReport::publicMessage( 'Код рубрики после сохранения не выполнен из-за ошибки базы данных', $e, 'DOCUPDATE', array('rubric_id' => (int) $doc['rubric_id'], 'document_id' => $id) ), array(), 500); } return $this->error( 'Код рубрики после сохранения остановил сохранение документа', array('rubric_code_end' => $e->getMessage()), 422 ); } catch (\Throwable $e) { DB::rollback(); if ($mediaFinalization) { $mediaFinalization->rollback(); } ContentCacheInvalidator::document($id, false); return $this->error(ErrorReport::publicMessage( 'Не удалось сохранить документ', $e, 'DOCUPDATE', array('rubric_id' => (int) $doc['rubric_id'], 'document_id' => $id) ), array(), 500); } finally { DB::$throw_exception_on_error = $previousDatabaseExceptionMode; } $snapshotError = ContentCacheInvalidator::consumeError($id); $snapshot = (new DocumentSnapshotRepository())->find($id); DocumentSaveEvents::after('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc, is_array($snapshot) ? $snapshot : array()); return $this->success('Документ сохранён', array('data' => array( 'id' => $id, 'document_version' => Model::documentVersion($id), 'snapshot_warning' => $snapshotError, 'media_draft_token' => DocumentMediaDraft::issue(Auth::id(), $id), ), 'redirect' => $this->base() . '/documents/' . $id . '/edit')); } public function destroy(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { if (!Model::delete(isset($params['id']) ? (int) $params['id'] : 0)) { return $this->error('Документ не найден', array(), 404); } } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } return $this->success('Документ помечен удалённым'); } public function revisions(array $params = array()) { $id = isset($params['id']) ? (int) $params['id'] : 0; $item = Model::one($id); if (!$item) { return $this->error('Документ не найден', array(), 404); } return $this->success('', array( 'data' => array( 'document' => array('id' => $item['Id'], 'title' => $item['document_title']), 'revisions' => Revisions::listForDocument($id), ), )); } public function snapshot(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!Model::one($id)) { return $this->error('Документ не найден', array(), 404); } return $this->success('', array('data' => $this->snapshotInfo($id))); } public function rebuildSnapshot(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $id = isset($params['id']) ? (int) $params['id'] : 0; $document = Model::one($id); if (!$document) { return $this->error('Документ не найден', array(), 404); } if (!empty($document['document_deleted'])) { ContentCacheInvalidator::document($id, false); return $this->error('Для удалённого документа JSON-снимок не создаётся', array(), 422); } if (!ContentCacheInvalidator::document($id, true)) { return $this->error(ContentCacheInvalidator::consumeError($id) ?: 'Не удалось пересобрать JSON-снимок', array(), 500); } return $this->success('JSON-снимок пересобран', array('data' => $this->snapshotInfo($id))); } public function rebuildSnapshots(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $after = max(0, Request::postInt('after', 0)); $rubricId = max(0, Request::postInt('rubric_id', 0)); $limit = max(1, min(100, Request::postInt('limit', 50))); $sql = 'SELECT Id FROM ' . Model::documentsTable() . " WHERE Id > %i AND document_deleted != '1'"; $args = array($after); if ($rubricId > 0) { $sql .= ' AND rubric_id = %i'; $args[] = $rubricId; } $sql .= ' ORDER BY Id ASC LIMIT %i'; $args[] = $limit + 1; array_unshift($args, $sql); $rows = call_user_func_array(array('DB', 'query'), $args)->getAll(); $hasMore = count($rows) > $limit; if ($hasMore) { array_pop($rows); } $done = 0; $failed = array(); $next = $after; foreach ($rows as $row) { $id = (int) $row['Id']; $next = $id; if (ContentCacheInvalidator::document($id, true)) { $done++; } else { $failed[] = array('id' => $id, 'error' => ContentCacheInvalidator::consumeError($id) ?: 'Неизвестная ошибка'); } } return $this->success('Пакет JSON-снимков обработан', array('data' => array( 'done' => $done, 'failed' => $failed, 'next_after' => $next, 'has_more' => $hasMore, ))); } public function aliases(array $params = array()) { $id = isset($params['id']) ? (int) $params['id'] : 0; $item = Model::one($id); if (!$item) { return $this->error('Документ не найден', array(), 404); } return $this->success('', array('data' => array( 'document' => array('id' => $id, 'title' => $item['document_title'], 'alias' => $item['document_alias']), 'aliases' => Model::aliasHistory($id), ))); } public function saveAlias(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { $id = Model::saveAliasHistory( isset($params['id']) ? (int) $params['id'] : 0, isset($params['alias']) ? (int) $params['alias'] : 0, Request::postAll(), Auth::id() ); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } return $this->success('Редирект сохранён', array('data' => array('id' => $id))); } public function deleteAlias(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } if (!Model::deleteAliasHistory( isset($params['id']) ? (int) $params['id'] : 0, isset($params['alias']) ? (int) $params['alias'] : 0 )) { return $this->error('Редирект не найден', array(), 404); } return $this->success('Редирект удалён'); } public function remarks(array $params = array()) { $id = isset($params['id']) ? (int) $params['id'] : 0; if (!Model::one($id)) { return $this->error('Документ не найден', array(), 404); } return $this->success('', array('data' => array('remarks' => Model::remarks($id)))); } public function addRemark(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { $id = Model::addRemark(isset($params['id']) ? (int) $params['id'] : 0, Request::postAll(), Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } return $this->success('Заметка добавлена', array('data' => array('id' => $id))); } public function deleteRemark(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } if (!Model::deleteRemark( isset($params['id']) ? (int) $params['id'] : 0, isset($params['remark']) ? (int) $params['remark'] : 0 )) { return $this->error('Заметка не найдена', array(), 404); } return $this->success('Заметка удалена'); } public function revision(array $params = array()) { if (!Permission::check('manage_documents')) { return $this->error('Недостаточно прав', array(), 403); } $revision = Revisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); if (!$revision) { return $this->error('Ревизия не найдена', array(), 404); } $document = Model::one((int) $revision['doc_id']); if (!$document) { return $this->error('Документ не найден', array(), 404); } $alias = trim(isset($document['document_alias']) ? (string) $document['document_alias'] : '', '/ '); $publicUrl = $alias === '' ? (defined('ABS_PATH') ? (string) ABS_PATH : '/') : (defined('ABS_PATH') ? (string) ABS_PATH : '/') . $alias . (defined('URL_SUFF') ? (string) URL_SUFF : ''); $publicUrl .= (strpos($publicUrl, '?') === false ? '?' : '&') . http_build_query(array( 'revision_preview' => (int) $revision['id'], 'preview_token' => DocumentRevisionPreview::issue((int) $revision['doc_id'], (int) $revision['id'], Auth::id()), )); $revision['preview_url'] = $publicUrl; return $this->success('', array('data' => $revision)); } public function restoreRevision(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { $selected = Request::postStr('selection_mode', '') === 'selected'; $documentKeys = Request::post('document_keys', array()); $fieldIds = Request::post('field_ids', array()); $documentId = $selected ? Revisions::restoreSelected( isset($params['revision']) ? (int) $params['revision'] : 0, is_array($documentKeys) ? $documentKeys : array(), is_array($fieldIds) ? $fieldIds : array(), Auth::id() ) : Revisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } return $this->success($selected ? 'Выбранные данные восстановлены из ревизии' : 'Документ восстановлен из ревизии', array( 'data' => array('id' => $documentId), 'redirect' => $this->base() . '/documents/' . $documentId . '/edit', )); } public function deleteRevision(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $documentId = Revisions::delete(isset($params['revision']) ? (int) $params['revision'] : 0); if (!$documentId) { return $this->error('Ревизия не найдена', array(), 404); } return $this->success('Ревизия удалена', array('data' => array('id' => $documentId))); } public function deleteRevisions(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!Model::one($id)) { return $this->error('Документ не найден', array(), 404); } $count = Revisions::deleteForDocument($id); return $this->success('Ревизии удалены', array('data' => array('id' => $id, 'count' => $count))); } public function restore(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } if (!Model::restore(isset($params['id']) ? (int) $params['id'] : 0)) { return $this->error('Документ не найден', array(), 404); } return $this->success('Документ восстановлен'); } public function toggle(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { $status = Model::toggleStatus(isset($params['id']) ? (int) $params['id'] : 0); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } if ($status === false) { return $this->error('Документ не найден', array(), 404); } return $this->success($status ? 'Документ опубликован' : 'Документ снят с публикации', array('data' => array('status' => $status))); } public function copy(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { $id = Model::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } return $this->success('Копия документа создана', array( 'data' => array('id' => $id), 'redirect' => $this->base() . '/documents/' . $id . '/edit', )); } public function saveCreationPreset(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $documentId = isset($params['id']) ? (int) $params['id'] : 0; try { $preset = (new DocumentCreationPresetRepository())->createFromDocument( $documentId, Request::postAll(), Auth::id() ); } catch (\InvalidArgumentException $e) { return $this->error('Проверьте настройки пресета', array('title' => $e->getMessage()), 422); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } AuditLog::record('document.creation_preset_created', array( 'actor_id' => Auth::id(), 'target_type' => 'document_creation_preset', 'target_id' => (int) $preset['id'], 'meta' => array( 'rubric_id' => (int) $preset['rubric_id'], 'source_document_id' => $documentId, 'fields_count' => (int) $preset['fields_count'], ), )); return $this->success('Пресет создания сохранён', array('data' => array('preset' => $preset))); } public function deleteCreationPreset(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } $preset = (new DocumentCreationPresetRepository())->delete( isset($params['preset']) ? (int) $params['preset'] : 0 ); if (!$preset) { return $this->error('Пресет не найден', array(), 404); } AuditLog::record('document.creation_preset_deleted', array( 'actor_id' => Auth::id(), 'target_type' => 'document_creation_preset', 'target_id' => (int) $preset['id'], 'meta' => array('rubric_id' => (int) $preset['rubric_id'], 'title' => (string) $preset['title']), )); return $this->success('Пресет удалён'); } public function purge(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } try { if (!Model::purge(isset($params['id']) ? (int) $params['id'] : 0)) { return $this->error('Документ не найден', array(), 404); } } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } return $this->success('Документ и связанные данные удалены окончательно'); } protected function guard() { return $this->guardPermission('manage_documents'); } protected function creationRubrics() { $rubrics = Model::rubrics(); $presets = (new DocumentCreationPresetRepository())->all(); $byRubric = array(); foreach ($presets as $preset) { $byRubric[(int) $preset['rubric_id']][] = $preset; } foreach ($rubrics as &$rubric) { $rubric['creation_presets'] = isset($byRubric[(int) $rubric['Id']]) ? $byRubric[(int) $rubric['Id']] : array(); } unset($rubric); return $rubrics; } protected function editConflict($documentId, $currentVersion) { return $this->json(array( 'success' => false, 'message' => 'Документ уже изменён в другой вкладке или другим пользователем.', 'data' => array( 'conflict' => true, 'current_version' => max(1, (int) $currentVersion), 'reload_url' => $this->base() . '/documents/' . (int) $documentId . '/edit', ), 'html' => new \stdClass(), 'redirect' => null, 'errors' => new \stdClass(), ), 409); } protected function snapshotInfo($documentId) { $store = new DocumentSnapshotStore(); $status = $store->status($documentId); $snapshot = $store->read($documentId); $document = Model::one($documentId); $current = false; if (is_array($snapshot) && $document) { try { $schema = (new RubricSchemaBuilder($store))->get((int) $document['rubric_id']); $current = isset($snapshot['format'], $snapshot['schema_version'], $snapshot['rubric_schema_version'], $snapshot['template_set_version']) && (string) $snapshot['format'] === DocumentSnapshotStore::FORMAT && (int) $snapshot['schema_version'] === DocumentSnapshotStore::VERSION && (string) $snapshot['rubric_schema_version'] === (string) $schema['version'] && (string) $snapshot['template_set_version'] === FieldTemplateManifest::version(); } catch (\Throwable $e) { $current = false; } } $status['current'] = $current; $status['fields_count'] = is_array($snapshot) && isset($snapshot['fields']) ? count($snapshot['fields']) : 0; $status['generated_label'] = !empty($status['generated_at']) ? date('d.m.Y H:i:s', $status['generated_at']) : 'ещё не создан'; $status['size_label'] = $this->bytes(isset($status['size']) ? (int) $status['size'] : 0); $status['path'] = str_replace(rtrim(BASEPATH, '/\\') . '/', '', (string) $status['path']); return $status; } protected function bytes($bytes) { if ($bytes < 1024) { return $bytes . ' Б'; } if ($bytes < 1048576) { return number_format($bytes / 1024, 1, ',', ' ') . ' КБ'; } return number_format($bytes / 1048576, 1, ',', ' ') . ' МБ'; } protected function validate(array $input, $id) { $errors = array(); $title = trim(isset($input['document_title']) ? (string) $input['document_title'] : ''); $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; $alias = trim(isset($input['document_alias']) ? (string) $input['document_alias'] : ''); $shortAlias = trim(isset($input['document_short_alias']) ? (string) $input['document_short_alias'] : ''); if ($title === '') { $errors['document_title'] = 'Укажите название документа'; } if ($rubricId <= 0 || !Model::rubric($rubricId)) { $errors['rubric_id'] = 'Выберите рубрику'; } if ($alias !== '' && !preg_match('/^[a-zA-Z0-9_\\-\\/\\.]+$/', $alias)) { $errors['document_alias'] = 'Допустимы латиница, цифры, точка, дефис, подчёркивание и слэш'; } elseif ($alias !== '' && ($conflict = Model::aliasConflict($alias, (int) $id))) { $errors['document_alias'] = 'URL уже занят: ' . (isset($conflict['label']) ? $conflict['label'] : 'другой раздел'); } if ($shortAlias !== '' && !preg_match('/^[a-zA-Z0-9_\-]+$/', $shortAlias)) { $errors['document_short_alias'] = 'Допустимы латиница, цифры, дефис и подчёркивание'; } elseif ($shortAlias !== '' && ($conflict = Model::aliasConflict($shortAlias, (int) $id))) { $errors['document_short_alias'] = 'URL уже занят: ' . (isset($conflict['label']) ? $conflict['label'] : 'другой раздел'); } return $errors; } }