Files
ave-cms/adminx/modules/Documents/Controller.php
T
2026-07-27 12:58:44 +03:00

1125 lines
42 KiB
PHP

<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Documents/Controller.php
| @author AVE.cms <support@ave-cms.ru>
| @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;
}
}