Данная статья может применяться не только Joomla, но и к любому другому PHP движку. Статью первоначально опубликовал в блоге на хабре. Копирую к себе.

Intro

Над данным кейсом трудились в разное время 2 разработчика: известный в Joomla-сообществе разработчик Артём Васильев (@kernUSR) и Ваш покорный слуга. Артёму Васильеву принадлежит в целом поиск и нахождение решения. Мне же осталось по описанию в переписке и примерам кода сделать плагин для Joomla 3 и Joomla 4 (ссылка на скачивание в конце статьи), а так же написать сию статью.

Небольшой дисклеймер: оба автора не сильны в вопросах криптографии и в решении задачи подходили с сугубо практической стороны. Если в тексте будут допущены неточности, то предложения по исправлению их и улучшению статьи приветствуются.

Контекст применения

Не секрет, что на Joomla CMS сделано очень много сайтов для образовательных учреждений - как начального звена, так и ССУЗов и ВУЗов. На сайты образовательных учреждений распространяется (на момент написания статьи) Приказ Рособрнадзора от 14.08.2020 №831 (ред. от 07.05.2021) "Об утверждении Требований к структуре официального сайта образовательной организации в информационно-телекоммуникационной сети "Интернет" и формату представления информации" (Зарегистрировано в Минюсте России 12.11.2020 N 60867). Также непосредственное влияние оказывает статья 29 Федерального закона от 29 декабря 2021 г. №273-ФЗ "Об образовании в Российской Федерации" и статья 6 Федерального закона от 6 апреля 2011г. №63-ФЗ "Об электронной подписи".

Согласно этим документам образовательное учреждение должно выкладывать на своём сайте документы как в текстовом виде, так и в виде файлов, подписанных "простой электронной подписью" (п.п. 3.2 и 6.г Приказа Рособрнадзора). Ситуация по учебным учреждениям страны очень и очень разная. Например, ВУЗ может позволить себе IT-отдел или как минимум системного администратора. А на уровне ССУЗов и школ может не оказаться IT-специалиста, который занимался бы только IT. Но, требования закона одинаковы и для столичного ВУЗа и для условной музыкальной школы небольшого уездного городка.

Немного об электронной подписи

Законом предусмотрены два типа электронных подписей: простая и усиленная. Последняя имеет две формы: квалифицированная и неквалифицированная.

Простая электронная подпись представляет собой комбинацию из логина и пароля и подтверждает, что электронное сообщение отправлено конкретным лицом.

<...>

Для того чтобы электронный документ считался подписанным простой электронной подписью необходимо выполнение в том числе одного из следующих условий:

1. простая электронная подпись содержится в самом электронном документе;

2. ключ простой электронной подписи применяется в соответствии с правилами, установленными оператором информационной системы, с использованием которой осуществляются создание и (или) отправка электронного документа, и в созданном и (или) отправленном электронном документе содержится информация, указывающая на лицо, от имени которого был создан и (или) отправлен электронный документ.

Ссылка на источник

Существует 2 вида подписей: открепленная и прикрепленная.

Открепленная электронная подпись создается в момент подписания электронного документа в отдельный файл непосредственно рядом с подписываемым файлом. Обычно это файл с таким же именем, как и подписываемый, но в формате *.sig.

Прикрепленная электронная подпись также создается в момент подписания документа, но сама подпись помещается внутрь подписываемого электронного документа. На выходе мы имеем один подписанный файл, что в целом соответствует требованию закона.

На практике у тех, кто не имеет IT-специалиста, разбирающегося в тонкостях ЭЦП, в шаговой доступности, нередко возникает вопрос: "Нужно ещё одну подпись покупать?" Ответ: нет. В каждой образовательной организации есть бухгалтерия. У бухгалтерии есть казначейская подпись "для госзакупок". Подписывать документы для сайта можно ей.

Подготовка документов к подписи с помощью ЭЦП

  1. Подготавливаем документы (как правило в формате MS Word). Проверяем, чтобы не было пустых страниц, была верная нумерация пунктов и т.д.

  2. Размещаем тексты необходимых документов в виде материалов Joomla. Поскольку "Требования" действуют довольно давно в различных своих версиях, тексты скорее всего уже были выложены. Их нужно обновить, так как некоторые локальные акты, учебные программы и планы принимаются каждый год. На сайте должны быть актуальные версии.

  3. Прямо из MS Word сохраняем тексты документов в формате PDF.

  4. Скачиваем и устанавливаем Adobe Reader DC. Он бесплатный. Инструкций по настройке Adobe Reader для подписи в сети много. На момент написания статьи ссылка на скачивание работала, программа скачивалась.

  5. Также потребуется, скорее всего, расширение для вашего криптопровайдера, например CryptoPro PDF. Для использования совместно с Adobe Reader этот модуль распространяется бесплатно.

Бесплатный CryptoPro PDF для Adobe Reader. Скриншот сайта
Бесплатный CryptoPro PDF для Adobe Reader.

Извлечения данных подписи из PDF в PHP

Хранение данных электронной подписи в формате PDF описывает спецификация Adobe. Также есть вопрос на StackOverflow How to retrieve digital signature information from PDF with PHP, который натолкнул [Артёма Васильева] на верные дальнейшие шаги.

В процессе подписи создаётся файл в формате pkcs7, который затем интегрируется внутрь PDF файла бинарном виде (специалисты в области криптографии меня поправят).

Далее из бинарника нужно вытащить в цепочку сертификатов в формате DER. И здесь начинаются сложности: дело в том, что openssl в php это сделать не может. Собрать такую сигнатуру у него возможность есть, а вот разобрать - нет. Проблема в том, что данные в нём записаны по алгоритму ASN.1. Поиск в интернете приводит обычно к ответам в духе "используйте shell_exec для запуска openssl" или "нафига оно надо - напишите на java микросервис", "используйте iText [Java и .NET библиотека] - я им очень доволен". Но, как мы понимаем, это решение не для условной провинциальной школы искусств.

Благо, обнаружилась библиотека, написанная на php, без сумасшедших зависимостей, которая умеет это всё читать, но она не знакома с российскими алгоритмами шифрования. Алгоритмы шифрования CP_GOST_R3411_12_256_R3410, CP_GOST_R3411_12_512_R3410 описаны в RFC.

скриншот сообщения в Telegram про электронную подпись, алгоритмы, RFC, Joomla
Скриншот сообщения из Telegram-канала Joomla по-русски

Артём Васильев изучил документацию ГОСТ Р-34.10 и ГОСТ Р-34.11, а также RFC7836 и дописал к найденной php библиотеке для работы с ASN.1 3 класса: 2 для работы с алгоритмами шифрования по ГОСТ и один для RFC7836. Слёзы гордости и радости можно почувствовать в оригинальном сообщении в Telegram-канале Joomla-сообщества.

Примеры кода

Извлечь pkcs7 из pdf на php можно регулярным выражением, так как подпись в файле выглядит как длинная кодированная строка между двух угловых скобок <>, находящаяся в массиве ByteRange.

Скриншот кода PDF-файла с массивом ByteRange
Скриншот кода PDF-файла с массивом ByteRange
<?php
$file_name = 'test_pdf_signed.pdf';
$content = file_get_contents(JPATH_SITE . '/' . $file_name);
$regexp = '#ByteRange\[\s*(\d+) (\d+) (\d+)#'; // subexpressions are used to extract b and c
$result = [];
preg_match_all($regexp, $content, $result);

Далее извлекается собственно подпись в бинарном виде.

<?php
$file_name = 'test_pdf_signed.pdf';
$content = file_get_contents(JPATH_SITE . '/' . $file_name);
$regexp = '#ByteRange\[\s*(\d+) (\d+) (\d+)#'; // регулярка для поиска подписи
$result = [];
preg_match_all($regexp, $content, $result);

if (isset($result[2]) && isset($result[3]) && isset($result[2][0])
			&& isset($result[3][0])
		)
		{
			$start = $result[2][0];
			$end   = $result[3][0];
			if ($stream = fopen(JPATH_SITE . '/' . $file_name, 'rb'))
			{
				$signature = stream_get_contents(
					$stream, $end - $start - 2, $start + 1
				); // Мы должны обрезать угловые скобки с начала и конца 

				fclose($stream);
			}
 }

Далее мы конвертируем шестнадцатеричные данные в двоичные и скармливаем их библиотеке для работы с алгоритмами.

Весь скрипт чтения данных электронной подписи из pdf на php без привязки к конкретному движку чуть ниже.

Для работы необходимы 6 библиотек:

Также можно установить их с помощью composer:

 

{
    "require": {
        "sop/asn1": "^4.1",
        "webmasterskaya/x509": "dev-master"
    },

    "minimum-stability": "dev"
}

 

Для корректной работы библиотек проверьте, что в PHP включены следующие расширения:

  • intl

  • gmp

  • mbstring

  • openssl

Код скрипта:

 

<?php

use Sop\ASN1\Element;
use Sop\ASN1\Type\Constructed\Sequence;
use Webmasterskaya\X509\Certificate\Certificate;

require_once __DIR__ . './vendor/autoload.php';

$file_name = 'test_pdf_signed.pdf';
$content = file_get_contents($file_name);

$regexp = '#ByteRange\[\s*(\d+) (\d+) (\d+)#'; // subexpressions are used to extract b and c

$result = [];
preg_match_all($regexp, $content, $result);

if (isset($result[2]) && isset($result[3]) && isset($result[2][0])
    && isset($result[3][0])
) {
    $start = $result[2][0];
    $end   = $result[3][0];
    if ($stream = fopen($file_name, 'rb')) {
        $signature = stream_get_contents(
            $stream, $end - $start - 2, $start + 1
        ); // because we need to exclude < and > from start and end

        fclose($stream);
    }

    if (!empty($signature)) {
        $binary = hex2bin($signature);

        $seq         = Sequence::fromDER($binary);
        $signed_data = $seq->getTagged(0)->asExplicit()->asSequence();
        $ecac        = $signed_data->getTagged(0)->asImplicit(Element::TYPE_SET)
            ->asSet();
        /** @var Sop\ASN1\Type\UnspecifiedType $ecoc */
        $ecoc = $ecac->at($ecac->count() - 1);
        $cert = Certificate::fromASN1($ecoc->asSequence());

        foreach ($cert->tbsCertificate()->subject()->all() as $attr) {
            /** @var Webmasterskaya\X501\ASN1\AttributeTypeAndValue $atv */
            $atv = $attr->getIterator()->current();
            echo $atv->type()->typeName() . ' : ' . $atv->value()->stringValue() . PHP_EOL;
        }
    }
}

В российских электронных подписях встречаются поля, которых нет в западных электронных подписях. Например, поля 1.2.643.3.131.1.1 = ИНН и 1.2.643.100.1 = ОГРН. Эти отличающиеся поля описаны в Приказе ФСБ России от 27.12.2011 N 795 (ред. от 29.01.2021) "Об утверждении Требований к форме квалифицированного сертификата ключа проверки электронной подписи" (Зарегистрировано в Минюсте России 27.01.2012 N 23041).

Чтение данных ЭЦП из PDF в Joomla 3 и Joomla 4

В Joomla тексты документов выкладываются материалами. Добавить к материалу pdf-файл для скачивания и отобразить информацию о подписанте из ЭЦП можно несколькими способами:

  1. Обычной ссылкой на файл в тексте материала. В таком случае на потребуется системный или контент-плагин, который будет обрабатывать все ссылки в материале, выделять из них ссылки только на pdf и далее проверять есть ли подпись.

  2. Сделать пользовательское поле для материалов и добавлять файлы только в эти поля. Здесь вариантов работы несколько от кустарных и неправильных, когда обработка ссылки будет идти прямо в шаблоне (не делайте так!) до оформления кода в плагин пользовательского поля. Вот простой мануал по созданию собственного плагина пользовательского поля для Joomla.

  3. Вставить специальный шорт-код. Можно вставить шорт-код, в параметрах которого будет указан путь к файлу для парсинга. В таком случае код плагина упростится, но чуть усложнится работа контент-менеджера.

Именно третий вариант я выбрал для создания плагина.

Создание плагина для Joomla

Материалы перед рендером обрабатывают плагины на событии onContentPrepare, где среди прочих данных мы получаем текст материала для обработки.

 

<?php 
defined('_JEXEC') or die;

/**
	 *
	 * @param   string   $context   The context of the content being passed to the plugin.
	 * @param   object   &$article  The article object.  Note $article->text is also available
	 * @param   mixed    &$params   The article params
	 * @param   integer  $page      The 'page' number
	 *
	 * @return  mixed   true if there is an error. Void otherwise.
	 *
	 * @since   1.6
	 */
public function onContentPrepare($context, &$article, &$params, $page = 0)
	{
		// Don't run this plugin when the content is being indexed
		if ($context === 'com_finder.indexer')
		{
			return true;
		}
  
  // Тут наш код.
  
}

Работа с библиотеками в Joomla

Для работы нашего кода нам требуются 6 библиотек. Пока что Joomla CMS не поддерживает установку пакетов с помощью composer (это в планах к Joomla 5), поэтому нам необходимо подготовить xml-обёртки библиотек для Joomla. Официальная документация по созданию собственных библиотек для Joomla.

Если кратко, нам нужно создать xml-файл, в котором мы расскажем Joomla что это вообще такое:

 

<?xml version="1.0" encoding="UTF-8" ?>
<extension type="library" version="3.10" method="upgrade">
	<name>Webmasterskaya/CryptoBridge Library</name>
	<libraryname>Webmasterskaya/CryptoBridge</libraryname>
	<version>0.3.1</version>
	<description>Russian fork of a SOP\CryptoBridge. A PHP library providing cryptography support for various PKCS applications. Defines an interface with encrypt / decrypt and signature signing / verification methods. Currently only OpenSSL backend is supported.</description>
	<creationDate>23/05/2019</creationDate>
	<copyright>Joni Eskelinen, Artem Vasilev</copyright>
	<license>MIT</license>
	<author>Joni Eskelinen, Artem Vasilev, Sergey Tolkachyov</author>
	<authorEmail>jonieske@gmail.com, kern.usr@gmail.com</authorEmail>
	<authorUrl>https://github.com/webmasterskaya/crypto-types</authorUrl>
	<files folder="libraries">
		<folder>Crypto</folder>
		<file>Crypto.php</file>
	</files>
</extension>

<name> - это отображаемое в списке расширений название библиотеки

<libraryname> - это директория библиотеки в /libraries.Если у Вас свой неймспейс и в нём несколько библиотек, то в <libraryname> мы через слеш указываем свой неймспейс.

Так, <libraryname>Webmasterskaya/CryptoBridge</libraryname> означает, что файлы будут лежать в корень_сайта/libraries/Webmasterskaya/CryptoBridge.

Для того, чтобы их использовать нужно зарегистрировать неймспейс этой библиотеки. Официальная документация рекомендует делать это с помощью простого системного плагина на событие onAfterInitialise()

<?php
/**
 * @copyright   Copyright (C) 2005 - 2013 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later.
 */

defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
/**
 * Mylib plugin class.
 *
 * @package     Joomla.plugin
 * @subpackage  System.mylib
 */
class plgSystemMylib extends CMSPlugin
{
    /**
     * Method to register custom library.
     *
     * return  void
     */
	public function onAfterInitialise()
	{			
		$jversion = new JVersion();
// Проверка какая версия Joomla используется.
		if (version_compare($jversion->getShortVersion(), '4.0', '<'))
		{
			// only for Joomla 3.x
			JLoader::registerNamespace('Sop', JPATH_LIBRARIES);
			JLoader::registerNamespace('Webmasterskaya', JPATH_LIBRARIES);
			JLoader::registerNamespace('Smalot', JPATH_LIBRARIES);

		}
		else
		{
			JLoader::registerNamespace('Sop', JPATH_LIBRARIES . '/Sop');
			JLoader::registerNamespace('Webmasterskaya', JPATH_LIBRARIES . '/Webmasterskaya');
			JLoader::registerNamespace('Smalot', JPATH_LIBRARIES. '/Smalot');
		}
	}
}

Различия в указании namespaces  в Joomla 3  и Joomla 4

В Joomla 3 следует указывать путь, после которого начинается namespace.В Joomla 4 следует указывать путь вплоть до каталога, где начинается namespace.

Готовые библиотеки для чтения ЭЦП в Joomla

Готовые для работы в Joomla библиотеки в xml-обёртках лежат здесь:

Также для получения даты последнего изменения PDF-файла я использую библиотеку Smalot/PDFParser.

По идее нужно 2 плагина: один для регистрации неймспейсов библиотек в группе system и один для обработки текста материалов в группе content. Разделение плагинов на группы позволяет не нагружать сервер и задействовать обработку плагинами только тогда, когда это действительно нужно, так как группа системных плагинов вызывается всегда, а плагины групп - каждая в определенном случае. Однако, предполагая, что на сайте школы не будет трафика более 5-10к уников в сутки, да и кэширование можно настроить, поэтому помещаем регистрацию неймспейсов и обработку текста в один плагин.

Метод onContentPrepare в Joomla

Для удобства шорткод сделаем с открывающим и закрывающим тегом. Для кода используем брендированную аббревиатуру, либо что-то интуитивно понятное. Я выбрал {wt_ds_pdf} - "WebTolk Digitally Signed PDF". В тексте материала $article->text нам нужно найти регуляркой все вхождения, считать путь между тегами шорткода и обработать его методом для получения данных ЭЦП из PDF.

<?php
defined('_JEXEC') or die('Restricted access');

use Joomla\CMS\Date\Date;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\FileLayout;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Factory;
use \Sop\ASN1\Element;
use \Sop\ASN1\Type\Constructed\Sequence;
use \Webmasterskaya\X509\Certificate\Certificate;
use \Smalot\PdfParser\Parser;

public function onContentPrepare($context, $article, $params, $limitstart = 0)
	{
		//Проверка есть ли строка замены в контенте
		if (strpos($article->text, 'wt_ds_pdf') === false)
		{
			return;
		}

		// Регулярка для поиска в тексте материала
		$regex = "~{wt_ds_pdf}.*?{/wt_ds_pdf}~is";


		// Получаем все вхождения
		if (preg_match_all($regex, $article->text, $matches, PREG_PATTERN_ORDER))
		{

			// Циклом проводим замену
			foreach ($matches[0] as $key => $match)
			{
				$pdf_file = preg_replace("/{.+?}/", "", $match);
				$pdf_file = str_replace(array('"', '\'', '`'), array('&quot;', '&apos;', '&#x60;'), $pdf_file); // Address potential XSS attacks

				$layoutId                          = $this->params->get('layout', 'default');
				$layout                            = new FileLayout($layoutId, JPATH_SITE . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'system' . DIRECTORY_SEPARATOR . 'wt_digitally_signed_pdf' . DIRECTORY_SEPARATOR . 'layouts');

        // наш метод для получения данных ЭЦП из PDF. Возвращает массив.
				$digital_sign_info                 = $this->getDigitallySignedPdfInfo($pdf_file);
				// Путь к файлу для вывода ссылки
        $digital_sign_info['link_to_file'] = $pdf_file;
				// Получаем HTML с помощью Joomla Layout
        $output                            = $layout->render($digital_sign_info);

				$article->text = str_replace($match, $output, $article->text);
			}

		}//end FOR

	} //onContentPrepare END

Для получения даты последнего изменения из мета-данных PDF-файла я подключил библиотеку Smalot/PDF Parser.

<?php
use \Smalot\PdfParser\Parser;
use Joomla\CMS\Date\Date;

$ModDate = new Date($pdf_meta_data['ModDate'], $timezone);
$date_modified       = $ModDate->format(Text::_('DATE_FORMAT_LC6'),true);

Использование Joomla Layout для результирующего HTML

Joomla построена по паттерну MVC, в который добавлен Layout. Layout позволяет создавать переопределения макетов, что является одной из важнейших и удобнейших особенностей Joomla. В плагине Вы создаете папку layouts, в которой лежат html-макеты вывода. В нём доступен массив данных $displayData (мы передаём его в методе $output = $layout->render($digital_sign_info); чуть ниже по коду). В настройках плагина Joomla я добавил выбор макета вывода (layout'а) информации о файле и электронной подписи. Соответственно Вы можете сверстать себе вывод просто ссылки со всплывающим тултипом, а можете использовать HTML5 тег <details> для того, чтобы удобно было просматривать информацию как на десктопах, так и на мобильных устройствах или любой другой вариант вывода. При желании плагин можно усовершенствовать и указывать в шорткоде дополнительный параметр, например, "tmpl=link" или "tmpl=html5_details".

<?php
// Файл layout'a
use Joomla\CMS\Language\Text;
use \Joomla\CMS\Date\Date;

defined('_JEXEC') or die('Restricted access');
/**
 * @var $displayData array Digital sign data
 * Use
 *      echo '<pre>';
 *		print_r($displayData);
 *		echo '</pre>';
 *
 * Информация в массиве с данными подписи может быть очень разная в зависимости от типа подписи, производителя.
 * Поэтому смотрим массив $displayData и отображаем только нужную информацию.
 * Array
 *   (
 *       [pdf_date_modified] => дата последнего изменения pdf-файла. Как правило, это дата подписания.
 *       [inn] => ИНН
 *       [snils] => СНИЛС
 *       [email] => электронная почта
 *       [country] => RU - двухсимвольный код страны
 *       [province] => регион/область
 *       [city] => город
 *       [organisation] => название организации
 *       [given_name] => имя и отчество должностного лица
 *       [surname] => фамилия должностного лица
 *       [common_name] => Ф.И.О. целиком
 *       [post] => должность
 *       [cert_date_start] => дата начала действия сертификата электронной подписи
 *       [cert_date_end] => дата окончания действия сертификата электронной подписи
 *       [serial_number] => серийный номер
 *       [link_to_file] => ссылка на файл
 *       [sign_icon] => иконка ЭЦП из настроек плагина
 *   )
 *
 */
$cert_date_start = new Date($displayData['cert_date_start']);
$cert_date_end = new Date($displayData['cert_date_end']);
$tooltip = 'Здесь отображаем нужную нам информацию. Хотя варианты вывода ограничны лишь Вашей фантазией.';
$tooltip .= '<br/><strong>Период действия сертификата</strong> '. $cert_date_start->format(Text::_('DATE_FORMAT_FILTER_DATE')).'-'.$cert_date_end->format(Text::_('DATE_FORMAT_FILTER_DATE'));
echo '<a href="'.$displayData['link_to_file'].'" class="hasTooltip" data-toggle="tooltip" data-html="true" title="'.$tooltip.'"><img src="'.$displayData['sign_icon'].'" alt="Документ подписан цифровой подписью"/>Скачать файл</a>'

Предложенный вариант layout'a является просто примером вывода. При использовании Joomla layouts становится возможным использовать переопределения макета и Joomla будет искать переопределения в шаблоне фронтенда и использовать их, если они обнаружены. При необходимости можно добавить свои пути, которые должны использоваться при поиске переопределений.

<?php
// Пример добавления путей в Joomla Layout из файлового менеджера Quantum manager
protected function getLayoutPaths()
	{
        $renderer = new FileLayout('default');
        $renderer->getDefaultIncludePaths();
        return array_merge(parent::getLayoutPaths(), [
            JPATH_ROOT . '/administrator/components/com_quantummanager/layouts/fields'
        ], $renderer->getDefaultIncludePaths());
	}

Демо видео работы плагина

Готовый плагин для Joomla 3 и Joomla 4

Скачать готовый плагин отображения данных ЭЦП из PDF для Joomla 3 и Joomla 4. Там же можно скачать все необходимые для его работы библиотеки.

Скриншот экрана установки Joomla 4 
Скриншот экрана установки Joomla 4
Настройки плагина - чекер библиотек, сами настройки чуть ниже. Joomla 4.
Настройки плагина - чекер библиотек, сами настройки чуть ниже. Joomla 4.
Шорт-код в тексте материала. Joomla 4.
Шорт-код в тексте материала. Joomla 4.
Работа плагина снаружи - во фронтенде. Joomla 4. Макет вывода - тег <details> HTML5.
Работа плагина снаружи - во фронтенде. Joomla 4. Макет вывода - тег <details> HTML5.
Толкачев Сергей Юрьевич
Толкачев Сергей Юрьевич

Joomla-разработчик. Контрибьютер ядра Joomla. Один из ведущих Telegram-канала русскоязычного Joomla-сообщества JoomlaFeed, один из модераторов чата русскоязычного Joomla-сообщества. Мои расширения в официальном маркетплейсе расширений Joomla - Joomla Extensions Directory. Имею публикации в официальном журнале международного Joomla-сообщества - Joomla Community Magazine.

Муж. Отец 3 детей.

Россия, Саратов.

Расширения Joomla WebTolk

90 Всего расширений
11 Категорий
402 Выпущено версий
397991 Всего скачиваний
Корзина
Корзина пуста