autowp/autowp

View on GitHub
module/Application/src/Controller/Api/PictureController.php

Summary

Maintainability
F
2 wks
Test Coverage
F
33%
<?php

namespace Application\Controller\Api;

use Application\Comments;
use Application\Controller\Plugin\Pic;
use Application\DuplicateFinder;
use Application\HostManager;
use Application\Hydrator\Api\AbstractRestHydrator;
use Application\Model\CarOfDay;
use Application\Model\Catalogue;
use Application\Model\Item;
use Application\Model\Picture;
use Application\Model\PictureItem;
use Application\Model\PictureModerVote;
use Application\Model\UserPicture;
use Application\Service\PictureService;
use Application\Service\TelegramService;
use ArrayAccess;
use ArrayObject;
use Autowp\Comments\CommentsService;
use Autowp\Image\Storage;
use Autowp\Message\MessageService;
use Autowp\TextStorage;
use Autowp\User\Controller\Plugin\User as UserPlugin;
use Autowp\User\Model\User;
use Exception;
use ImagickException;
use Laminas\ApiTools\ApiProblem\ApiProblem;
use Laminas\ApiTools\ApiProblem\ApiProblemResponse;
use Laminas\Db\Sql;
use Laminas\Http\PhpEnvironment\Request;
use Laminas\Http\PhpEnvironment\Response;
use Laminas\InputFilter\InputFilter;
use Laminas\InputFilter\InputFilterInterface;
use Laminas\Mvc\Controller\AbstractRestfulController;
use Laminas\Stdlib\ResponseInterface;
use Laminas\Uri\Uri;
use Laminas\View\Model\JsonModel;
use Laminas\View\Model\ViewModel;

use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_replace;
use function count;
use function explode;
use function get_object_vars;
use function htmlspecialchars;
use function implode;
use function in_array;
use function max;
use function min;
use function round;
use function sprintf;
use function strlen;
use function urlencode;

/**
 * @method Pic pic()
 * @method string language()
 * @method UserPlugin user($user = null)
 * @method ApiProblemResponse inputFilterResponse(InputFilterInterface $inputFilter)
 * @method ViewModel forbiddenAction()
 * @method void log(string $message, array $objects)
 * @method string translate(string $message, string $textDomain = 'default', $locale = null)
 */
class PictureController extends AbstractRestfulController
{
    private CarOfDay $carOfDay;

    private AbstractRestHydrator $hydrator;

    private PictureItem $pictureItem;

    private DuplicateFinder $duplicateFinder;

    private UserPicture $userPicture;

    private HostManager $hostManager;

    private InputFilter $itemInputFilter;

    private InputFilter $postInputFilter;

    private InputFilter $listInputFilter;

    private InputFilter $publicListInputFilter;

    private InputFilter $editInputFilter;

    private TextStorage\Service $textStorage;

    private CommentsService $comments;

    private PictureModerVote $pictureModerVote;

    private Item $item;

    private Picture $picture;

    private User $userModel;

    private PictureService $pictureService;

    private TelegramService $telegram;

    private MessageService $message;

    private Catalogue $catalogue;

    private Storage $imageStorage;

    public function __construct(
        AbstractRestHydrator $hydrator,
        PictureItem $pictureItem,
        DuplicateFinder $duplicateFinder,
        UserPicture $userPicture,
        HostManager $hostManager,
        TelegramService $telegram,
        MessageService $message,
        CarOfDay $carOfDay,
        InputFilter $itemInputFilter,
        InputFilter $postInputFilter,
        InputFilter $listInputFilter,
        InputFilter $publicListInputFilter,
        InputFilter $editInputFilter,
        TextStorage\Service $textStorage,
        CommentsService $comments,
        PictureModerVote $pictureModerVote,
        Item $item,
        Picture $picture,
        User $userModel,
        PictureService $pictureService,
        Catalogue $catalogue,
        Storage $imageStorage
    ) {
        $this->carOfDay = $carOfDay;

        $this->hydrator              = $hydrator;
        $this->pictureItem           = $pictureItem;
        $this->duplicateFinder       = $duplicateFinder;
        $this->userPicture           = $userPicture;
        $this->hostManager           = $hostManager;
        $this->telegram              = $telegram;
        $this->message               = $message;
        $this->itemInputFilter       = $itemInputFilter;
        $this->postInputFilter       = $postInputFilter;
        $this->listInputFilter       = $listInputFilter;
        $this->publicListInputFilter = $publicListInputFilter;
        $this->editInputFilter       = $editInputFilter;
        $this->textStorage           = $textStorage;
        $this->comments              = $comments;
        $this->pictureModerVote      = $pictureModerVote;
        $this->picture               = $picture;
        $this->item                  = $item;
        $this->userModel             = $userModel;
        $this->pictureService        = $pictureService;
        $this->catalogue             = $catalogue;
        $this->imageStorage          = $imageStorage;
    }

    /**
     * @return array|JsonModel
     * @throws Exception
     */
    public function canonicalRouteAction()
    {
        /** @psalm-suppress InvalidCast */
        $picture = $this->picture->getRow(['identity' => (string) $this->params('id')]);

        if (! $picture) {
            return $this->notFoundAction();
        }

        $route = null;

        $itemIds = $this->pictureItem->getPictureItems($picture['id'], PictureItem::PICTURE_CONTENT);
        if ($itemIds) {
            $itemIds = $this->item->getIds([
                'id'           => $itemIds,
                'item_type_id' => [Item::BRAND, Item::VEHICLE, Item::ENGINE, Item::PERSON],
            ]);

            if ($itemIds) {
                $carId = $itemIds[0];

                $paths = $this->catalogue->getCataloguePaths($carId, [
                    'breakOnFirst' => true,
                    'stockFirst'   => true,
                    'toBrand'      => false,
                ]);

                if (count($paths) > 0) {
                    $path = $paths[0];

                    switch ($path['type']) {
                        case 'brand':
                            if ($path['car_catname']) {
                                $route = array_merge(
                                    ['/', $path['brand_catname'], $path['car_catname']],
                                    $path['path'],
                                    ['pictures', $picture['identity']]
                                );
                            } else {
                                $perspectiveId = $this->pictureItem->getPerspective($picture['id'], $carId);

                                switch ($perspectiveId) {
                                    case 22:
                                        $action = 'logotypes';
                                        break;
                                    case 25:
                                        $action = 'mixed';
                                        break;
                                    default:
                                        $action = 'other';
                                        break;
                                }

                                $route = ['/', $path['brand_catname'], $action, $picture['identity']];
                            }
                            break;
                        case 'brand-item':
                            $route = array_merge(
                                ['/', $path['brand_catname'], $path['car_catname']],
                                $path['path'],
                                ['pictures',  $picture['identity']]
                            );
                            break;
                        case 'category':
                            $route = ['/category', $path['category_catname'], 'pictures', $picture['identity']];
                            break;
                        case 'person':
                            $route = ['/persons', $path['id'], $picture['identity']];
                            break;
                    }
                }
            }
        }

        return new JsonModel($route);
    }

    /**
     * @throws Storage\Exception
     * @throws Exception
     */
    public function randomPictureAction(): JsonModel
    {
        $pictureRow = $this->picture->getRow([
            'status' => Picture::STATUS_ACCEPTED,
            'order'  => 'random',
        ]);

        $result = [
            'status' => false,
        ];

        if ($pictureRow) {
            $imageInfo = $this->imageStorage->getImage($pictureRow['image_id']);

            $uri = $this->hostManager->getUriByLanguage($this->language());
            $uri->setPath('/picture/' . urlencode($pictureRow['identity']));

            $result = [
                'status' => true,
                'url'    => $imageInfo->getSrc(),
                'name'   => $this->pic()->name($pictureRow, $this->language()),
                'page'   => $uri->toString(),
            ];
        }

        return new JsonModel($result);
    }

    /**
     * @throws Storage\Exception
     * @throws Exception
     */
    public function newPictureAction(): JsonModel
    {
        $pictureRow = $this->picture->getRow([
            'status' => Picture::STATUS_ACCEPTED,
            'order'  => 'accept_datetime_desc',
        ]);

        $result = [
            'status' => false,
        ];

        if ($pictureRow) {
            $imageInfo = $this->imageStorage->getImage($pictureRow['image_id']);

            $uri = $this->hostManager->getUriByLanguage($this->language());
            $uri->setPath('/picture/' . urlencode($pictureRow['identity']));

            $result = [
                'status' => true,
                'url'    => $imageInfo->getSrc(),
                'name'   => $this->pic()->name($pictureRow, $this->language()),
                'page'   => $uri->toString(),
            ];
        }

        return new JsonModel($result);
    }

    /**
     * @throws Storage\Exception
     * @throws Exception
     */
    public function carOfDayPictureAction(): JsonModel
    {
        $itemOfDay = $this->carOfDay->getCurrent();

        $pictureRow = null;

        if ($itemOfDay) {
            $carRow = $this->item->getRow(['id' => (int) $itemOfDay['item_id']]);
            if ($carRow) {
                foreach ([31, null] as $groupId) {
                    $filter = [
                        'status' => Picture::STATUS_ACCEPTED,
                        'item'   => [
                            'ancestor_or_self' => $carRow['id'],
                        ],
                        'order'  => 'resolution_desc',
                    ];

                    if ($groupId) {
                        $filter['item']['perspective'] = [
                            'group' => $groupId,
                        ];
                        $filter['order']               = 'perspective_group';
                    }

                    $pictureRow = $this->picture->getRow($filter);
                    if ($pictureRow) {
                        break;
                    }
                }
            }
        }

        $result = [
            'status' => false,
        ];

        if ($pictureRow) {
            $imageInfo = $this->imageStorage->getImage($pictureRow['image_id']);

            $uri = $this->hostManager->getUriByLanguage($this->language());
            $uri->setPath('/picture/' . urlencode($pictureRow['identity']));

            $result = [
                'status' => true,
                'url'    => $imageInfo->getSrc(),
                'name'   => $this->pic()->name($pictureRow, $this->language()),
                'page'   => $uri->toString(),
            ];
        }

        return new JsonModel($result);
    }

    /**
     * @return ViewModel|ResponseInterface|array
     */
    public function indexAction()
    {
        $isModer = $this->user()->enforce('global', 'moderate');
        $user    = $this->user()->get();

        $inputFilter = $isModer ? $this->listInputFilter : $this->publicListInputFilter;
        $inputFilter->setData($this->params()->fromQuery());

        if (! $inputFilter->isValid()) {
            return $this->inputFilterResponse($inputFilter);
        }

        $data = $inputFilter->getValues();

        if ($data['status'] === 'inbox' && ! $user) {
            return new ApiProblemResponse(
                new ApiProblem(400, 'Data is invalid. Check `detail`.', null, 'Validation error', [
                    'invalid_params' => [
                        'item_id' => [
                            'invalid' => 'inbox not allowed anonymously',
                        ],
                    ],
                ])
            );
        }

        $restricted = ! $isModer && ! $data['exact_item_id'] && ! $data['item_id'] && ! $data['owner_id'] &&
            ! $data['status'] && ! $data['identity'];
        if ($restricted) {
            return new ApiProblemResponse(
                new ApiProblem(400, 'Data is invalid. Check `detail`.', null, 'Validation error', [
                    'invalid_params' => [
                        'item_id' => [
                            'invalid' => 'item_id or owner_id is required',
                        ],
                    ],
                ])
            );
        }

        $filter = [
            'timezone' => $this->user()->timezone(),
        ];

        if ($data['identity']) {
            $filter['identity'] = $data['identity'];
        }

        if ($data['item_id']) {
            $filter['item']['ancestor_or_self']['id'] = $data['item_id'];
        }

        if ($data['owner_id']) {
            $filter['user'] = $data['owner_id'];
        }

        $orders = [
            1  => 'add_date_desc',
            2  => 'add_date_asc',
            3  => 'resolution_desc',
            4  => 'resolution_asc',
            5  => 'filesize_desc',
            6  => 'filesize_asc',
            7  => 'comments',
            8  => 'views',
            9  => 'moder_votes',
            10 => 'similarity',
            11 => 'removing_date',
            12 => 'likes',
            13 => 'dislikes',
            14 => 'status',
            15 => 'accept_datetime_desc',
            16 => 'perspectives',
        ];

        switch ($data['order']) {
            case 13:
                $filter['has_dislikes'] = true;
                break;
        }

        if ($data['order'] && isset($orders[$data['order']])) {
            $filter['order'] = $orders[$data['order']];
        } else {
            $filter['order'] = $orders[1];
        }

        if (strlen($data['status'])) {
            switch ($data['status']) {
                case Picture::STATUS_INBOX:
                case Picture::STATUS_ACCEPTED:
                case Picture::STATUS_REMOVING:
                    $filter['status'] = $data['status'];
                    break;
                case 'custom1':
                    $filter['status'] = [
                        Picture::STATUS_INBOX,
                        Picture::STATUS_ACCEPTED,
                    ];
                    break;
            }
        }

        if (strlen($data['add_date'])) {
            $filter['add_date'] = $data['add_date'];
        }

        if (strlen($data['accept_date'])) {
            $filter['accept_date'] = $data['accept_date'];
        }

        if ($data['perspective_id']) {
            if ($data['perspective_id'] === 'null') {
                $filter['item']['perspective_is_null'] = true;
            } else {
                $filter['item']['perspective'] = (int) $data['perspective_id'];
            }
        }

        if ($data['perspective_exclude_id']) {
            $parts = explode(',', $data['perspective_exclude_id']);
            $value = [];
            foreach ($parts as $part) {
                $part = (int) $part;
                if ($part) {
                    $value[] = $part;
                }
            }

            $filter['item']['perspective_exclude'] = $value;
        }

        if ($data['exact_item_id']) {
            $filter['item']['id'] = $data['exact_item_id'];
        }

        if ($data['exact_item_link_type']) {
            $filter['item']['link_type'] = $data['exact_item_link_type'];
        }

        if ($data['accepted_in_days']) {
            $filter['accepted_in_days'] = $data['accepted_in_days'];
        }

        if ($isModer) {
            if (strlen($data['comments'])) {
                if ($data['comments'] === '1') {
                    $filter['has_comments'] = true;
                } elseif ($data['comments'] === '0') {
                    $filter['has_comments'] = false;
                }
            }

            if ($data['car_type_id']) {
                $filter['item']['vehicle_type'] = $data['car_type_id'];
            }

            if ($data['special_name']) {
                $filter['has_special_name'] = true;
            }

            if ($data['similar']) {
                $filter['has_similar'] = true;
                $data['order']         = 10;
            }

            if (strlen($data['requests'])) {
                switch ($data['requests']) {
                    case '0':
                        $filter['has_moder_votes'] = false;
                        break;

                    case '1':
                        $filter['has_accept_votes'] = true;
                        break;

                    case '2':
                        $filter['has_delete_votes'] = true;
                        break;

                    case '3':
                        $filter['has_moder_votes'] = true;
                        break;
                }
            }

            if (strlen($data['replace'])) {
                if ($data['replace'] === '1') {
                    $filter['is_replace'] = true;
                } elseif ($data['replace'] === '0') {
                    $filter['is_replace'] = false;
                }
            }

            if ($data['lost']) {
                $filter['is_lost'] = true;
            }

            if ($data['gps']) {
                $filter['has_point'] = true;
            }

            if ($data['added_from']) {
                $filter['added_from'] = $data['added_from'];
            }

            if ($data['exclude_item_id']) {
                $filter['item']['exclude_ancestor_or_self']['id'] = $data['exclude_item_id'];
            }
        }

        $paginator = $this->picture->getPaginator($filter);

        if (strlen($data['limit']) > 0) {
            $limit = (int) $data['limit'];
            $limit = $limit >= 0 ? $limit : 0;
        } else {
            $limit = 1;
        }

        $paginator
            ->setItemCountPerPage($limit ?: 1)
            ->setCurrentPageNumber($data['page']);

        $result = [
            'paginator' => get_object_vars($paginator->getPages()),
        ];

        if ($limit > 0) {
            $this->hydrator->setOptions([
                'language'  => $this->language(),
                'user_id'   => $user ? $user['id'] : null,
                'fields'    => $data['fields'],
                'item_id'   => (int) $data['item_id'],
                'paginator' => $data['paginator'],
            ]);

            $pictures = [];
            foreach ($paginator->getCurrentItems() as $pictureRow) {
                $pictures[] = $this->hydrator->extract($pictureRow);
            }
            $result['pictures'] = $pictures;
        }

        return new JsonModel($result);
    }

    /**
     * @param array|ArrayAccess $picture
     */
    private function canAccept($picture): bool
    {
        return $this->picture->canAccept($picture)
            && $this->user()->enforce('picture', 'accept');
    }

    /**
     * @param array|ArrayObject $user
     */
    private function userModerUrl($user, Uri $uri): string
    {
        $u = clone $uri;
        $u->setPath('/users/' . ($user['identity'] ? $user['identity'] : 'user' . $user['id']));

        return $u->toString();
    }

    /**
     * @throws Storage\Exception
     * @throws ImagickException
     * @return ViewModel|ResponseInterface|array
     */
    public function postAction()
    {
        $user = $this->user()->get();
        if (! $user) {
            return $this->forbiddenAction();
        }

        /** @var Request $request */
        $request = $this->getRequest();

        $data = array_merge(
            $this->params()->fromPost(),
            $request->getFiles()->toArray()
        );

        $this->postInputFilter->setData($data);
        if (! $this->postInputFilter->isValid()) {
            return $this->inputFilterResponse($this->postInputFilter);
        }

        $values = $this->postInputFilter->getValues();

        $itemId           = (int) $values['item_id'];
        $replacePictureId = (int) $values['replace_picture_id'];
        $perspectiveId    = (int) $values['perspective_id'];

        if (! $itemId && ! $replacePictureId) {
            return new ApiProblemResponse(
                new ApiProblem(400, 'Data is invalid. Check `detail`.', null, 'Validation error', [
                    'invalid_params' => [
                        'item_id' => [
                            'invalid' => 'item_id or replace_picture_id is required',
                        ],
                    ],
                ])
            );
        }

        /** @var Request $request */
        $request = $this->getRequest();

        $picture = $this->pictureService->addPictureFromFile(
            $values['file']['tmp_name'],
            $user['id'],
            $request->getServer('REMOTE_ADDR'),
            $itemId,
            $perspectiveId,
            $replacePictureId,
            (string) $values['comment']
        );

        $url = $this->url()->fromRoute('api/picture/picture/item', [
            'id' => $picture['id'],
        ]);

        /** @var Response $response */
        $response = $this->getResponse();
        $response->getHeaders()->addHeaderLine('Location', $url);
        return $response->setStatusCode(Response::STATUS_CODE_201);
    }

    /**
     * @throws Exception
     * @return ViewModel|ResponseInterface|array
     */
    public function updateAction()
    {
        $user = $this->user()->get();
        if (! $user) {
            return $this->forbiddenAction();
        }

        /** @psalm-suppress InvalidCast */
        $id      = (int) $this->params('id');
        $picture = $this->picture->getRow(['id' => $id]);

        if (! $picture) {
            return $this->notFoundAction();
        }

        $data            = (array) $this->processBodyContent($this->getRequest());
        $validationGroup = array_keys($data); // TODO: intersect with real keys
        if (! $validationGroup) {
            return $this->forbiddenAction();
        }
        $this->editInputFilter->setValidationGroup($validationGroup);
        $this->editInputFilter->setData($data);

        if (! $this->editInputFilter->isValid()) {
            return $this->inputFilterResponse($this->editInputFilter);
        }

        $data = $this->editInputFilter->getValues();

        $isModer = $this->user()->enforce('global', 'moderate');

        $set = [];

        if (isset($data['crop'])) {
            $canCrop = $this->user()->enforce('picture', 'crop')
                    || ($picture['owner_id'] === $user['id']) && ($picture['status'] === Picture::STATUS_INBOX);

            if (! $canCrop) {
                return $this->forbiddenAction();
            }

            $left   = round($data['crop']['left']);
            $top    = round($data['crop']['top']);
            $width  = round($data['crop']['width']);
            $height = round($data['crop']['height']);

            $left  = max(0, $left);
            $left  = min($picture['width'], $left);
            $width = max(1, $width);
            $width = min($picture['width'], $width);

            $top    = max(0, $top);
            $top    = min($picture['height'], $top);
            $height = max(1, $height);
            $height = min($picture['height'], $height);

            $crop = null;
            if ($left > 0 || $top > 0 || $width < $picture['width'] || $height < $picture['height']) {
                $crop = [
                    'left'   => $left,
                    'top'    => $top,
                    'width'  => $width,
                    'height' => $height,
                ];
            }

            $this->imageStorage->setImageCrop((int) $picture['image_id'], $crop);

            $this->log(sprintf(
                'Выделение области на картинке %s',
                htmlspecialchars($this->pic()->name($picture, $this->language()))
            ), [
                'pictures' => $picture['id'],
            ]);
        }

        if ($isModer) {
            if (array_key_exists('replace_picture_id', $data)) {
                if ($picture['replace_picture_id'] && ! $data['replace_picture_id']) {
                    $replacePicture = $this->picture->getRow(['id' => (int) $picture['replace_picture_id']]);
                    if (! $replacePicture) {
                        return $this->notFoundAction();
                    }

                    if (! $this->user()->enforce('picture', 'move')) {
                        return $this->forbiddenAction();
                    }

                    $set['replace_picture_id'] = null;

                    // log
                    $this->log(sprintf(
                        'Замена %s на %s отклонена',
                        htmlspecialchars($this->pic()->name($replacePicture, $this->language())),
                        htmlspecialchars($this->pic()->name($picture, $this->language()))
                    ), [
                        'pictures' => [$picture['id'], $replacePicture['id']],
                    ]);
                }
            }

            if (array_key_exists('taken_year', $data)) {
                $set['taken_year'] = $data['taken_year'];
            }

            if (array_key_exists('taken_month', $data)) {
                $set['taken_month'] = $data['taken_month'];
            }

            if (array_key_exists('taken_day', $data)) {
                $set['taken_day'] = $data['taken_day'];
            }

            if (isset($data['special_name'])) {
                $set['name'] = $data['special_name'];
            }

            if (isset($data['copyrights'])) {
                $text = $data['copyrights'];

                $user = $this->user()->get();

                if ($picture['copyrights_text_id']) {
                    $this->textStorage->setText($picture['copyrights_text_id'], $text, $user['id']);
                } elseif ($text) {
                    $textId                    = $this->textStorage->createText($text, $user['id']);
                    $set['copyrights_text_id'] = $textId;
                }

                $this->log(sprintf(
                    'Редактирование текста копирайтов изображения %s',
                    htmlspecialchars($this->pic()->name($picture, $this->language()))
                ), [
                    'pictures' => $picture['id'],
                ]);

                if ($picture['copyrights_text_id']) {
                    $userIds = $this->textStorage->getTextUserIds($picture['copyrights_text_id']);

                    foreach ($userIds as $userId) {
                        if ($userId !== (int) $user['id']) {
                            $userRow = $this->userModel->getRow((int) $userId);
                            if ($userRow) {
                                $uri = $this->hostManager->getUriByLanguage($userRow['language']);
                                $uri->setPath('/moder/pictures/' . $picture['id']);

                                $message = sprintf(
                                    $this->translate(
                                        'pm/user-%s-edited-picture-copyrights-%s-%s',
                                        'default',
                                        $userRow['language']
                                    ),
                                    $this->userModerUrl($user, $uri),
                                    $this->pic()->name($picture, $userRow['language']),
                                    $uri->toString()
                                );

                                $this->message->send(null, $userRow['id'], $message);
                            }
                        }
                    }
                }
            }

            if (isset($data['status'])) {
                $user                 = $this->user()->get();
                $previousStatusUserId = (int) $picture['change_status_user_id'];

                if ($data['status'] === Picture::STATUS_ACCEPTED) {
                    $canAccept = $this->canAccept($picture);

                    if (! $canAccept) {
                        return $this->forbiddenAction();
                    }

                    $isFirstTimeAccepted = false;
                    $success             = $this->picture->accept($picture['id'], $user['id'], $isFirstTimeAccepted);
                    if ($success) {
                        $owner = $this->userModel->getRow((int) $picture['owner_id']);

                        if ($owner) {
                            $this->userPicture->refreshPicturesCount($owner['id']);
                        }

                        if ($isFirstTimeAccepted) {
                            if ($owner && ((int) $owner['id'] !== (int) $user['id'])) {
                                $uri = $this->hostManager->getUriByLanguage($owner['language']);

                                $uri->setPath('/picture/' . urlencode($picture['identity']));

                                $message = sprintf(
                                    $this->translate('pm/your-picture-accepted-%s', 'default', $owner['language']),
                                    $uri->toString()
                                );

                                $this->message->send(null, $owner['id'], $message);
                            }

                            $this->telegram->notifyPicture($picture['id']);
                        }
                    }

                    if ($previousStatusUserId !== (int) $user['id']) {
                        $prevUser = $this->userModel->getRow($previousStatusUserId);
                        if ($prevUser) {
                            $uri = $this->hostManager->getUriByLanguage($prevUser['language']);

                            $uri->setPath('/picture/' . urlencode($picture['identity']))->toString();

                            $message = sprintf(
                                'Принята картинка %s',
                                $uri->toString()
                            );
                            $this->message->send(null, $prevUser['id'], $message);
                        }
                    }

                    $this->log(sprintf(
                        'Картинка %s принята',
                        htmlspecialchars($this->pic()->name($picture, $this->language()))
                    ), [
                        'pictures' => $picture['id'],
                    ]);
                }

                if ($data['status'] === Picture::STATUS_INBOX) {
                    if ($picture['status'] === Picture::STATUS_REMOVING) {
                        $canRestore = $this->user()->enforce('picture', 'restore');
                        if (! $canRestore) {
                            return $this->forbiddenAction();
                        }

                        $set = array_replace($set, [
                            'status'                => Picture::STATUS_INBOX,
                            'change_status_user_id' => $user['id'],
                        ]);

                        $this->log(sprintf(
                            'Картинки `%s` восстановлена из очереди удаления',
                            htmlspecialchars($this->pic()->name($picture, $this->language()))
                        ), [
                            'pictures' => $picture['id'],
                        ]);
                    } elseif ($picture['status'] === Picture::STATUS_ACCEPTED) {
                        $canUnaccept = $this->user()->enforce('picture', 'unaccept');
                        if (! $canUnaccept) {
                            return $this->forbiddenAction();
                        }

                        $this->picture->getTable()->update([
                            'status'                => Picture::STATUS_INBOX,
                            'change_status_user_id' => $user['id'],
                        ], [
                            'id' => $picture['id'],
                        ]);

                        if ($picture['owner_id']) {
                            $this->userPicture->refreshPicturesCount($picture['owner_id']);
                        }

                        $this->log(sprintf(
                            'С картинки %s снят статус "принято"',
                            htmlspecialchars($this->pic()->name($picture, $this->language()))
                        ), [
                            'pictures' => $picture['id'],
                        ]);

                        if ($previousStatusUserId !== (int) $user['id']) {
                            $prevUser = $this->userModel->getRow($previousStatusUserId);
                            if ($prevUser) {
                                $uri = $this->hostManager->getUriByLanguage($prevUser['language']);

                                $uri->setPath('/picture/' . urlencode($picture['identity']))->toString();

                                $message = sprintf(
                                    'С картинки %s снят статус "принято"',
                                    $uri->toString()
                                );
                                $this->message->send(null, $prevUser['id'], $message);
                            }
                        }
                    }
                }

                if ($data['status'] === Picture::STATUS_REMOVING) {
                    $canDelete = $this->pictureCanDelete($picture);
                    if (! $canDelete) {
                        return $this->forbiddenAction();
                    }

                    $user = $this->user()->get();
                    $set  = array_replace($set, [
                        'status'                => Picture::STATUS_REMOVING,
                        'removing_date'         => new Sql\Expression('CURDATE()'),
                        'change_status_user_id' => $user['id'],
                    ]);

                    $owner = $this->userModel->getRow((int) $picture['owner_id']);
                    if ($owner && (int) $owner['id'] !== (int) $user['id']) {
                        $uri = $this->hostManager->getUriByLanguage($owner['language']);

                        $deleteRequests = $this->pictureModerVote->getNegativeVotes($picture['id']);

                        $reasons = [];
                        foreach ($deleteRequests as $request) {
                            $user = $this->userModel->getRow((int) $request['user_id']);
                            if ($user) {
                                $reasons[] = $this->userModerUrl($user, $uri) . ' : ' . $request['reason'];
                            }
                        }

                        $uri->setPath('/picture/' . urlencode($picture['identity']));

                        $message = sprintf(
                            $this->translate('pm/your-picture-%s-enqueued-to-remove-%s', 'default', $owner['language']),
                            $uri->toString(),
                            implode("\n", $reasons)
                        );

                        $this->message->send(null, $owner['id'], $message);
                    }

                    $this->log(sprintf(
                        'Картинка %s поставлена в очередь на удаление',
                        htmlspecialchars($this->pic()->name($picture, $this->language()))
                    ), [
                        'pictures' => $picture['id'],
                    ]);
                }
            }

            if (isset($data['point']['lat'], $data['point']['lng'])) {
                if ($data['point']['lat'] && $data['point']['lng']) {
                    $set['point'] = new Sql\Expression('Point(?, ?)', [$data['point']['lng'], $data['point']['lat']]);
                } else {
                    $set['point'] = null;
                }
            }
        }

        if ($set) {
            $this->picture->getTable()->update($set, [
                'id' => $picture['id'],
            ]);
        }

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_200);
    }

    /**
     * @throws Exception
     * @return ViewModel|ResponseInterface|array
     */
    public function itemAction()
    {
        $user = $this->user()->get();

        if (! $user) {
            return $this->forbiddenAction();
        }

        $this->itemInputFilter->setData($this->params()->fromQuery());

        if (! $this->itemInputFilter->isValid()) {
            return $this->inputFilterResponse($this->itemInputFilter);
        }

        $data = $this->itemInputFilter->getValues();

        $this->hydrator->setOptions([
            'language' => $this->language(),
            'user_id'  => $user['id'],
            'fields'   => $data['fields'],
        ]);

        /** @psalm-suppress InvalidCast */
        $id  = (int) $this->params('id');
        $row = $this->picture->getRow(['id' => $id]);
        if (! $row) {
            return $this->notFoundAction();
        }

        return new JsonModel($this->hydrator->extract($row));
    }

    /**
     * @param array|ArrayAccess $picture
     * @throws Exception
     */
    private function pictureCanDelete($picture): bool
    {
        if (! $this->picture->canDelete($picture)) {
            return false;
        }

        $canDelete = false;
        $user      = $this->user()->get();
        if ($this->user()->enforce('picture', 'remove')) {
            if ($this->pictureModerVote->hasVote($picture['id'], $user['id'])) {
                $canDelete = true;
            }
        } elseif ($this->user()->enforce('picture', 'remove_by_vote')) {
            if ($this->pictureModerVote->hasVote($picture['id'], $user['id'])) {
                $acceptVotes = $this->pictureModerVote->getPositiveVotesCount($picture['id']);
                $deleteVotes = $this->pictureModerVote->getNegativeVotesCount($picture['id']);

                $canDelete = $deleteVotes > $acceptVotes;
            }
        }

        return $canDelete;
    }

    /**
     * @return ViewModel|ResponseInterface|array
     * @throws Exception
     * @throws Storage\Exception
     */
    public function normalizeAction()
    {
        if (! $this->user()->enforce('global', 'moderate')) {
            return $this->forbiddenAction();
        }

        /** @psalm-suppress InvalidCast */
        $id  = (int) $this->params('id');
        $row = $this->picture->getRow(['id' => $id]);
        if (! $row) {
            return $this->notFoundAction();
        }

        $canNormalize = $row['status'] === Picture::STATUS_INBOX
                     && $this->user()->enforce('picture', 'normalize');

        if (! $canNormalize) {
            return $this->forbiddenAction();
        }

        if ($row['image_id']) {
            $this->imageStorage->normalize($row['image_id']);
        }

        $this->log(sprintf(
            'К картинке %s применён normalize',
            htmlspecialchars($this->pic()->name($row, $this->language()))
        ), [
            'pictures' => $row['id'],
        ]);

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_200);
    }

    /**
     * @return ViewModel|ResponseInterface|array
     * @throws Exception
     * @throws Storage\Exception
     */
    public function flopAction()
    {
        if (! $this->user()->enforce('global', 'moderate')) {
            return $this->forbiddenAction();
        }

        /** @psalm-suppress InvalidCast */
        $id  = (int) $this->params('id');
        $row = $this->picture->getRow(['id' => $id]);
        if (! $row) {
            return $this->notFoundAction();
        }

        $canFlop = $row['status'] === Picture::STATUS_INBOX
                && $this->user()->enforce('picture', 'flop');

        if (! $canFlop) {
            return $this->forbiddenAction();
        }

        if ($row['image_id']) {
            $this->imageStorage->flop($row['image_id']);
        }

        $this->log(sprintf(
            'К картинке %s применён flop',
            htmlspecialchars($this->pic()->name($row, $this->language()))
        ), [
            'pictures' => $row['id'],
        ]);

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_200);
    }

    /**
     * @throws Exception
     * @return ViewModel|ResponseInterface|array
     */
    public function repairAction()
    {
        if (! $this->user()->enforce('global', 'moderate')) {
            return $this->forbiddenAction();
        }

        /** @psalm-suppress InvalidCast */
        $id  = (int) $this->params('id');
        $row = $this->picture->getRow(['id' => $id]);
        if (! $row) {
            return $this->notFoundAction();
        }

        if ($row['image_id']) {
            $this->imageStorage->flush([
                'image' => $row['image_id'],
            ]);
        }

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_200);
    }

    /**
     * @return ViewModel|ResponseInterface|array
     * @throws Exception
     * @throws Storage\Exception
     */
    public function correctFileNamesAction()
    {
        if (! $this->user()->enforce('global', 'moderate')) {
            return $this->forbiddenAction();
        }

        /** @psalm-suppress InvalidCast */
        $id  = (int) $this->params('id');
        $row = $this->picture->getRow(['id' => $id]);
        if (! $row) {
            return $this->notFoundAction();
        }

        if ($row['image_id']) {
            $this->imageStorage->changeImageName($row['image_id'], [
                'pattern' => $this->picture->getFileNamePattern($row['id']),
            ]);
        }

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_200);
    }

    /**
     * @throws Exception
     * @return ViewModel|ResponseInterface|array
     */
    public function deleteSimilarAction()
    {
        /** @psalm-suppress InvalidCast */
        $id         = (int) $this->params('id');
        $srcPicture = $this->picture->getRow(['id' => $id]);
        /** @psalm-suppress InvalidCast */
        $similarPictureId = (int) $this->params('similar_picture_id');
        $dstPicture       = $this->picture->getRow(['id' => $similarPictureId]);

        if (! $srcPicture || ! $dstPicture) {
            return $this->notFoundAction();
        }

        $this->duplicateFinder->hideSimilar($srcPicture['id'], $dstPicture['id']);

        $this->log('Отменёно предупреждение о повторе', [
            'pictures' => [$srcPicture['id'], $dstPicture['id']],
        ]);

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_204);
    }

    /**
     * @param array|ArrayAccess $picture
     * @param array|ArrayAccess $replacedPicture
     */
    private function canReplace($picture, $replacedPicture): bool
    {
        $can1 = false;
        switch ($picture['status']) {
            case Picture::STATUS_ACCEPTED:
                $can1 = true;
                break;

            case Picture::STATUS_INBOX:
                $can1 = $this->user()->enforce('picture', 'accept');
                break;
        }

        $can2 = false;
        switch ($replacedPicture['status']) {
            case Picture::STATUS_ACCEPTED:
                $can2 = $this->user()->enforce('picture', 'unaccept')
                     && $this->user()->enforce('picture', 'remove_by_vote');
                break;

            case Picture::STATUS_INBOX:
                $can2 = $this->user()->enforce('picture', 'remove_by_vote');
                break;

            case Picture::STATUS_REMOVING:
            case Picture::STATUS_REMOVED:
                $can2 = true;
                break;
        }

        return $can1 && $can2 && $this->user()->enforce('picture', 'move');
    }

    /**
     * @throws Exception
     * @return ViewModel|ResponseInterface|array
     */
    public function acceptReplaceAction()
    {
        if (! $this->user()->enforce('global', 'moderate')) {
            return $this->forbiddenAction();
        }

        /** @psalm-suppress InvalidCast */
        $id      = (int) $this->params('id');
        $picture = $this->picture->getRow(['id' => $id]);
        if (! $picture) {
            return $this->notFoundAction();
        }

        if (! $picture['replace_picture_id']) {
            return $this->notFoundAction();
        }

        $replacePicture = $this->picture->getRow(['id' => (int) $picture['replace_picture_id']]);
        if (! $replacePicture) {
            return $this->notFoundAction();
        }

        if (! $this->canReplace($picture, $replacePicture)) {
            return $this->forbiddenAction();
        }

        $user = $this->user()->get();

        // statuses
        if ($picture['status'] !== Picture::STATUS_ACCEPTED) {
            $set = [
                'status'                => Picture::STATUS_ACCEPTED,
                'change_status_user_id' => $user['id'],
            ];
            if (! $picture['accept_datetime']) {
                $set['accept_datetime'] = new Sql\Expression('NOW()');
            }

            $this->picture->getTable()->update($set, [
                'id' => $picture['id'],
            ]);

            if ($picture['owner_id']) {
                $this->userPicture->refreshPicturesCount($picture['owner_id']);
            }
        }

        if (! in_array($replacePicture['status'], [Picture::STATUS_REMOVING, Picture::STATUS_REMOVED])) {
            $this->picture->getTable()->update([
                'status'                => Picture::STATUS_REMOVING,
                'removing_date'         => new Sql\Expression('now()'),
                'change_status_user_id' => $user['id'],
            ], [
                'id' => $replacePicture['id'],
            ]);
            if ($replacePicture['owner_id']) {
                $this->userPicture->refreshPicturesCount($replacePicture['owner_id']);
            }
        }

        // comments
        $this->comments->moveMessages(
            Comments::PICTURES_TYPE_ID,
            $replacePicture['id'],
            Comments::PICTURES_TYPE_ID,
            $picture['id']
        );

        // pms
        $owner        = $this->userModel->getRow((int) $picture['owner_id']);
        $replaceOwner = $this->userModel->getRow((int) $replacePicture['owner_id']);
        $recepients   = [];
        if ($owner) {
            $recepients[$owner['id']] = $owner;
        }
        if ($replaceOwner) {
            $recepients[$replaceOwner['id']] = $replaceOwner;
        }
        unset($recepients[$user['id']]);
        if ($recepients) {
            foreach ($recepients as $recepient) {
                $uri = $this->hostManager->getUriByLanguage($recepient['language']);

                $url        = $uri->setPath('/picture/' . urlencode($picture['identity']))->toString();
                $replaceUrl = $uri->setPath('/picture/' . urlencode($replacePicture['identity']))->toString();
                $moderUrl   = $uri->setPath('/users/' . ($user['identity'] ? $user['identity'] : 'user' . $user['id']))
                                ->toString();

                $message = sprintf(
                    $this->translate('pm/user-%s-accept-replace-%s-%s', 'default', $recepient['language']),
                    $moderUrl,
                    $replaceUrl,
                    $url
                );

                $this->message->send(null, $recepient['id'], $message);
            }
        }

        // log
        $this->log(sprintf(
            'Замена %s на %s',
            htmlspecialchars($this->pic()->name($replacePicture, $this->language())),
            htmlspecialchars($this->pic()->name($picture, $this->language()))
        ), [
            'pictures' => [$picture['id'], $replacePicture['id']],
        ]);

        /** @var Response $response */
        $response = $this->getResponse();
        return $response->setStatusCode(Response::STATUS_CODE_200);
    }

    /**
     * @return ViewModel|ResponseInterface|array
     */
    public function userSummaryAction()
    {
        $user = $this->user()->get();

        if (! $user) {
            return $this->forbiddenAction();
        }

        $acceptedCount = $this->picture->getCount([
            'status' => Picture::STATUS_ACCEPTED,
            'user'   => $user['id'],
        ]);

        $inboxCount = $this->picture->getCount([
            'status' => Picture::STATUS_INBOX,
            'user'   => $user['id'],
        ]);

        return new JsonModel([
            'inboxCount'    => $inboxCount,
            'acceptedCount' => $acceptedCount,
        ]);
    }
}