+7 (958) 580-59-59

на связи с 9:00 до 21:00

Коннектор для Creatium

С помощью данного кода вы можете с лёгкостью подтягивать данные из MySQL и PostgreSQL базы на сайты созданные в Creatium.

Авторы творения

Создатель Марк Гаджимурадов: https://t.me/m_ivor

Доработал Вечкасов Кирилл: https://t.me/pomogay_marketing

Документация API для работы с базой данных

Описание

API предоставляет универсальный интерфейс для работы с базами данных PostgreSQL и MySQL через HTTP-запросы с расширенными возможностями фильтрации, сортировки и типизации данных.

Конфигурация

Настройки подключения

$dbType = 'pgsql'; // 'pgsql' или 'mysql'
$secretHash = 'c9b939a42d2f5d76196661333b9d0e9'; // секретный ключ доступа

Параметры кеширования

$enableCache = false;           // включить общее кеширование
$enableAlwaysCache = false; // принудительное кеширование для указанных таблиц
$alwaysCacheTables = ''; // таблицы для принудительного кеша, если у вас psql база, указываете полный путь до таблицы public.table_1, public.table_2
$cacheHours = 0; // время жизни кеша в часах (0 = бессрочно)

Авторизация

Обязательный параметр: hash в URL

GET /api.php?table=my_table&hash=c9b939a42d2f5d7619666174eeb9d0e9

Без корректного hash-кода доступ к данным заблокирован.

Основные параметры запроса

URL параметры

  • table (обязательный) - имя таблицы для запроса
  • limit - максимальное количество записей
  • skip - количество записей для пропуска (offset)

Пример базового запроса

GET /api.php?table=users&hash=YOUR_HASH&limit=10&skip=20

Фильтрация данных

Фильтры передаются в теле POST-запроса в формате JSON:

{
"properties": {
"name": "John",
"age>=": 18,
"status!=": "deleted"
}
}

Операторы фильтрации

ОператорОписаниеПримерSQL эквивалент
=Равенство"name": "John"name = 'John'
!=Неравенство"status!=": "deleted"status != 'deleted'
>Больше"age>": 18age > 18
<Меньше"age<": 65age < 65
>=Больше или равно"age>=": 18age >= 18
<=Меньше или равно"age<=": 65age <= 65
~Содержит"name~": "John"name LIKE '%John%'
!~Не содержит"name!~": "spam"name NOT LIKE '%spam%'
^Начинается с"name^": "John"name LIKE 'John%'
!^Не начинается с"name!^": "spam"name NOT LIKE 'spam%'
&Заканчивается на"email&": ".com"email LIKE '%.com'
!&Не заканчивается на"email!&": ".test"email NOT LIKE '%.test'
@IN (список значений)"status@": "1,2,3"status IN (1,2,3)
$DISTINCT"category$": ""SELECT DISTINCT category

Специальные операторы

Пустые значения

{
"name!=": "" // Исключает пустые строки И NULL значения
}

Генерирует: name != '' AND name IS NOT NULL

Группировка значений

Одинаковые операторы автоматически группируются:

{
"status": ["active", "pending"],
"category": ["news", "blog"]
}

Генерирует: status IN ('active', 'pending') AND category IN ('news', 'blog')

DISTINCT запросы

{
"properties": {
"category$": "any_value" // значение может быть пустым
}
}

Результат: SELECT DISTINCT category FROM table

Логические группы (AND/OR)

По умолчанию все условия объединяются через AND. Для создания OR-групп используйте директивы:

{
"properties": {
"name": "John",
"age&gt;=": 18,
"FILTER:OR": "",
"status": "vip",
"balance&gt;": 1000,
"FILTER:AND": "",
"created&gt;=": "2024-01-01"
}
}

Результат: (name = 'John' AND age >= 18) OR (status = 'vip' AND balance > 1000) AND (created >= '2024-01-01')

Сортировка

Сортировка задается в JSON теле запроса:

{
"sort": [
{"column": "created_at", "direction": "DESC"},
{"column": "name", "direction": "ASC"}
]
}

Пагинация

Через URL параметры

GET /api.php?table=users&amp;limit=10&amp;skip=20&amp;hash=YOUR_HASH

Через JSON тело

{
"limit": 10,
"skip": 20
}

Автоматическая типизация столбцов

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

Типы столбцов

1. Image Type (type: "image")

Условия определения:

  • В комментарии столбца есть слово "image" (регистронезависимо)
  • В названии столбца есть "img" или "image"

Примеры столбцов:

  • avatar_img
  • product_image
  • cover_img
  • thumbnail_image

Обработка данных:

  • URL изображений автоматически преобразуются в объекты:
// Было: "https://example.com/image.jpg"
// Стало:
{
"file": "https://example.com/image.jpg",
"size": [1920, 1080]
}

Поддерживаемые форматы: jpg, jpeg, png, webp, gif

2. Markdown Type (type: "markdown")

Условия определения:

  • В комментарии столбца есть слово "markdown" (регистронезависимо)
  • В названии столбца есть "markdown"

Примеры столбцов:

  • content_markdown
  • description_markdown
  • markdown_text
  • post_markdown

3. Стандартные типы

String - текстовые данные (по умолчанию)

Number - числовые данные (int, float)

Boolean - логические значения

Комментарии столбцов

Система автоматически извлекает комментарии из метаданных базы данных:

PostgreSQL: Использует col_description()

MySQL: Использует INFORMATION_SCHEMA.COLUMNS

Комментарии используются для:

  1. Определения типа столбца
  2. Отображаемого имени (если комментарий не пустой)

Структура ответа

{
"columns": [
{
"id": "name",
"name": "Имя пользователя",
"type": "string",
"comment": "Имя пользователя"
},
{
"id": "avatar_img",
"name": "avatar_img",
"type": "image",
"comment": ""
}
],
"rows": [
{
"id": 1,
"name": "John Doe",
"avatar_img": {
"file": "https://example.com/avatar.jpg",
"size": [1920, 1080]
}
}
],
"totalCount": 156
}

Примеры использования

1. Простой запрос всех записей

curl -X GET "https://yoursite.com/api.php?table=users&amp;hash=YOUR_HASH"

2. Фильтрация с пагинацией

curl -X POST "https://yoursite.com/api.php?table=users&amp;hash=YOUR_HASH&amp;limit=10" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"status": "active",
"age&gt;=": 18
},
"sort": [
{"column": "created_at", "direction": "DESC"}
]
}'

3. Получение уникальных значений

curl -X POST "https://yoursite.com/api.php?table=posts&amp;hash=YOUR_HASH" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"category$": "1"
}
}'

4. Сложная фильтрация с OR группами

curl -X POST "https://yoursite.com/api.php?table=products&amp;hash=YOUR_HASH" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"price&gt;=": 100,
"FILTER:OR": "",
"category": "electronics",
"brand~": "Apple"
}
}'

Кеширование

Система поддерживает гибкое кеширование:

Глобальное кеширование

$enableCache = true;
$cacheHours = 1; // кеш на 1 час

Исключения из кеша

$noCacheTables = 'logs,temp_data'; // эти таблицы не кешируются

Принудительное кеширование

$enableAlwaysCache = true;
$alwaysCacheTables = 'static_content,settings'; // всегда кешируются

Отладка

Система создает лог-файлы для отладки:

  • input_json.log - входящие JSON запросы
  • debug_sql.log - сгенерированные SQL запросы и параметры

Ошибки

400 - Bad Request

{"error": "Table name is required."}

403 - Forbidden

HTML страница с сообщением "Для загрузки данных введите секретный ключ"

500 - Internal Server Error

{"error": "Database connection failed"}

Безопасность

  1. Обязательная авторизация через секретный hash
  2. Параметризованные запросы для защиты от SQL-инъекций
  3. Валидация типов данных перед выполнением запросов
  4. Логирование всех операций для аудита

Обновления

10 июня 2025

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

КОД ДЛЯ КОПИРОВАНИЯ

<?php
/* ===================== Конфигурация ===================== */
// Тип подключения к базе данных: 'mysql' или 'pgsql'
$dbType = 'mysql'; // изменить на 'pgsql' для подключения к PostgreSQL

// Секретный HASH код для доступа к данным
$secretHash = '****';
/* ====================================================== */

// Проверка наличия и корректности секретного HASH
if (!isset($_GET['hash']) || $_GET['hash'] !== $secretHash) {
echo '<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Ошибка доступа</title>
<style>
body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f9f9f9; }
.message { font-size: 2em; text-align: center; color: #333; }
</style>
</head>
<body>
<div class="message">Для загрузки данных введите секретный ключ</div>
</body>
</html>';
exit;
}

// ===================== Настройки кеширования =====================
$enableCache = false;
$noCacheTables = '';
$cacheHours = 0;
$enableAlwaysCache = false;
$alwaysCacheTables = '****';
// =================================================================

// Параметры подключения к базе данных
$host = '****';
$username = '****';
$password = '****';
$dbname = ($dbType === 'pgsql') ? 'public' : 'mysql';
$port = ($dbType === 'pgsql') ? '5432' : '3306';

// Получаем имя таблицы из GET-параметра 'table'
$table = $_GET['table'] ?? null;
if (!$table) {
http_response_code(400);
echo json_encode(['error' => 'Table name is required.']);
exit;
}

// Подготовка списков кеширования
$noCacheTableList = array_map('trim', explode(',', $noCacheTables));
$alwaysCacheTableList = array_map('trim', explode(',', $alwaysCacheTables));
$shouldUseCache =
($enableCache && !in_array($table, $noCacheTableList))
|| ($enableAlwaysCache && in_array($table, $alwaysCacheTableList));

/**
* Определение типа столбца
*/
function getColumnType($value) {
if (is_bool($value)) return 'string';
if (is_int($value) || is_float($value)) return 'number';
return 'string';
}

/**
* Получение комментариев столбцов из базы данных
*/
function getColumnComments($pdo, $table, $dbType) {
$comments = [];

try {
if ($dbType === 'pgsql') {
// Для PostgreSQL
$parts = explode('.', $table);
if (count($parts) === 2) {
$schema = $parts[0];
$tableName = $parts[1];
} else {
$schema = 'public';
$tableName = $table;
}

$sql = "
SELECT
c.column_name,
COALESCE(col_description(pgc.oid, c.ordinal_position), '') as comment
FROM information_schema.columns c
LEFT JOIN pg_class pgc ON pgc.relname = c.table_name
LEFT JOIN pg_namespace pgn ON pgn.oid = pgc.relnamespace
WHERE c.table_schema = :schema
AND c.table_name = :table_name
AND (pgn.nspname = :schema2 OR pgn.nspname IS NULL)
";

$stmt = $pdo->prepare($sql);
$stmt->execute([
'schema' => $schema,
'table_name' => $tableName,
'schema2' => $schema
]);

} else {
// Для MySQL
$parts = explode('.', $table);
if (count($parts) === 2) {
$database = $parts[0];
$tableName = $parts[1];
} else {
// Получаем текущую базу данных
$dbResult = $pdo->query("SELECT DATABASE()")->fetchColumn();
$database = $dbResult ?: 'mysql';
$tableName = $table;
}

$sql = "
SELECT
COLUMN_NAME as column_name,
COALESCE(COLUMN_COMMENT, '') as comment
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = :database
AND TABLE_NAME = :table_name
";

$stmt = $pdo->prepare($sql);
$stmt->execute([
'database' => $database,
'table_name' => $tableName
]);
}

while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$comments[$row['column_name']] = $row['comment'] ?: '';
}

} catch (Exception $e) {
// В случае ошибки возвращаем пустой массив и логируем
error_log("Error in getColumnComments: " . $e->getMessage());
return [];
}

return $comments;
}

// Читаем JSON из тела запроса
$input = json_decode(file_get_contents('php://input'), true) ?: [];

// Сразу же логируем его в отдельный файл
$logFile = __DIR__ . '/input_json.log';
$logEntry = date('c')
. " | INPUT JSON: " . json_encode($input, JSON_UNESCAPED_UNICODE)
. "\n";
file_put_contents($logFile, $logEntry, FILE_APPEND);

// Блок кеширования
if ($shouldUseCache) {
$cacheDir = __DIR__ . '/cache_treba_online_ru';
if (!file_exists($cacheDir)) mkdir($cacheDir, 0777, true);
$cacheKey = md5(json_encode($_GET) . json_encode($input));
$cacheFile = "$cacheDir/{$cacheKey}.json";
$cacheValid = $cacheHours === 0
? file_exists($cacheFile)
: (file_exists($cacheFile) && time() - filemtime($cacheFile) < $cacheHours * 3600);
if ($cacheValid) {
header('Content-Type: application/json');
echo file_get_contents($cacheFile);
exit;
}
}

try {
// Подключение к БД
if ($dbType === 'pgsql') {
$dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";
} else {
$dsn = "mysql:host={$host};port={$port};dbname={$dbname};charset=utf8";
}
$pdo = new PDO($dsn, $username, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

// Получаем комментарии столбцов (с обработкой ошибок)
$colComments = [];
try {
$colComments = getColumnComments($pdo, $table, $dbType);
} catch (Exception $e) {
// Логируем ошибку, но продолжаем работу без комментариев
error_log("Error getting column comments: " . $e->getMessage());
}

$params = [];
$distinctColumns = [];
$groups = [];
$currentGroup = ['joiner' => 'AND', 'conditions' => [], 'grouped' => []];
$globalCounter = 0;

if (!empty($input['properties']) && is_array($input['properties'])) {
foreach ($input['properties'] as $property => $value) {
// GROUP directives FILTER:AND/FILTER:OR
if (stripos($property, 'FILTER:') === 0) {
$directive = strtoupper(substr($property, 7));
if (in_array($directive, ['AND', 'OR'])) {
// flush grouped conditions
foreach ($currentGroup['grouped'] as $col => $ops) {
foreach ($ops as $op => $vals) {
if (count($vals) > 1) {
$pls = [];
foreach ($vals as $v) {
$globalCounter++;
$ph = "{$col}_" . str_replace(['=', '!'], '', $op) . "_{$globalCounter}";
$pls[] = ":{$ph}";
$params[$ph] = $v;
}
$inOp = $op === '=' ? 'IN' : 'NOT IN';
$currentGroup['conditions'][] = "{$col} {$inOp} (" . implode(', ', $pls) . ")";
} else {
$globalCounter++;
$ph = "{$col}_" . str_replace(['=', '!'], '', $op) . "_{$globalCounter}";
$currentGroup['conditions'][] = "{$col} {$op} :{$ph}";
$params[$ph] = $vals[0];
}
}
}
if (!empty($currentGroup['conditions'])) $groups[] = $currentGroup;
$currentGroup = ['joiner' => $directive, 'conditions' => [], 'grouped' => []];
continue;
}
}

// parse operator and column
$cleanKey = preg_replace('/\d+$/', '', $property);
// Список всех операторов, добавили '@'
$ops = ['>=','<=','!=','!~','!^','!&','!%','>','<','=','~','^','&','$','#','%','@'];
$operator = '=';
$column = $cleanKey;
foreach ($ops as $op) {
if (substr($cleanKey, -strlen($op)) === $op) {
$operator = $op;
$column = substr($cleanKey, 0, -strlen($op));
break;
}
}

// Новый оператор '@': разбивает строку "1,2,3" на три отдельных условия AND
if ($operator === '@') {
// если пришло не array, а строка — разбиваем по запятым
$values = is_array($value) ? $value : explode(',', $value);
$placeholders = [];
foreach ($values as $v) {
$v = trim($v);
$globalCounter++;
$ph = "{$column}_at_{$globalCounter}";
$placeholders[] = ":{$ph}";
$params[$ph] = $v;
}
// добавляем одно условие IN (...)
$currentGroup['conditions'][] = "{$column} IN (" . implode(', ', $placeholders) . ")";
// и пропускаем дальнейшую логику для этого оператора
continue;
}

// special: != empty => exclude empty & NULL
if ($operator === '!=' && ($value === '' || $value === null)) {
$currentGroup['conditions'][] = "{$column} != ''";
$currentGroup['conditions'][] = "{$column} IS NOT NULL";
continue;
}

// skip empty only for '='
$isEmptyScalar = !is_array($value) && ($value === '' || $value === null);
$isEmptyArray = is_array($value) && empty($value);
if ($operator === '=' && ($isEmptyScalar || $isEmptyArray)) {
continue;
}

// DISTINCT
if ($operator === '$') {
// Проверяем, что значение не пустое
if (!empty($value) || $value === 0 || $value === '0') {
$distinctColumns[] = $column;
}
continue;
}

// '=' and '!=' grouping
if (in_array($operator, ['=', '!='])) {
$split = is_array($value) ? $value : [$value];
foreach ($split as $v) {
$currentGroup['grouped'][$column][$operator][] = $v;
}
continue;
}

// other operators
$globalCounter++;
$ph = "{$column}_" . preg_replace('~[^A-Za-z0-9]~', '', $operator) . "_{$globalCounter}";
switch ($operator) {
case '~':
$currentGroup['conditions'][] = "{$column} LIKE :{$ph}";
$params[$ph] = "%{$value}%";
break;
case '!~':
$currentGroup['conditions'][] = "{$column} NOT LIKE :{$ph}";
$params[$ph] = "%{$value}%";
break;
case '^':
$currentGroup['conditions'][] = "{$column} LIKE :{$ph}";
$params[$ph] = "{$value}%";
break;
case '!^':
$currentGroup['conditions'][] = "{$column} NOT LIKE :{$ph}";
$params[$ph] = "{$value}%";
break;
case '&':
$currentGroup['conditions'][] = "{$column} LIKE :{$ph}";
$params[$ph] = "%{$value}";
break;
case '!&':
$currentGroup['conditions'][] = "{$column} NOT LIKE :{$ph}";
$params[$ph] = "%{$value}";
break;
default:
$currentGroup['conditions'][] = "{$column} {$operator} :{$ph}";
$params[$ph] = $value;
}
}

// flush last grouped
foreach ($currentGroup['grouped'] as $col => $ops) {
foreach ($ops as $op => $vals) {
if (count($vals) > 1) {
$pls = [];
foreach($vals as $v) {
$globalCounter++;
$ph = "{$col}_" . str_replace(['=', '!'], '', $op) . "_{$globalCounter}";
$pls[] = ":{$ph}";
$params[$ph] = $v;
}
$inOp = $op === '=' ? 'IN' : 'NOT IN';
$currentGroup['conditions'][] = "{$col} {$inOp} (" . implode(', ', $pls) . ")";
} else {
$globalCounter++;
$ph = "{$col}_" . str_replace(['=', '!'], '', $op) . "_{$globalCounter}";
$currentGroup['conditions'][] = "{$col} {$op} :{$ph}";
$params[$ph] = $vals[0];
}
}
}
if (!empty($currentGroup['conditions'])) $groups[] = $currentGroup;
}

// build WHERE
$where = '';
$clauses = [];
foreach ($groups as $g) {
if (count($g['conditions']) > 1) {
$clauses[] = '(' . implode(" {$g['joiner']} ", $g['conditions']) . ')';
} elseif (count($g['conditions']) === 1) {
$clauses[] = $g['conditions'][0];
}
}
if ($clauses) $where = 'WHERE ' . implode(' AND ', $clauses);

// sorting/limit/offset
$orderBy = '';
if (!empty($input['sort']) && is_array($input['sort'])) {
$parts = [];
foreach ($input['sort'] as $s) {
$parts[] = "{$s['column']} {$s['direction']}";
}
$orderBy = 'ORDER BY ' . implode(', ', $parts);
}
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : ($input['limit'] ?? 0);
$limitClause = $limit ? "LIMIT {$limit}" : '';
$offset = isset($input['skip'])
? (int)$input['skip']
: (isset($_GET['skip']) ? (int)$_GET['skip'] : 0);

$offsetClause = $offset ? "OFFSET {$offset}" : '';

// SELECT vs DISTINCT
if (!empty($distinctColumns)) {
$select = 'SELECT DISTINCT ' . implode(', ', $distinctColumns);
$countQ = "SELECT COUNT(*) FROM (SELECT DISTINCT " . implode(', ', $distinctColumns) . " FROM {$table} {$where}) as sub";
} else {
$select = 'SELECT *';
$countQ = "SELECT COUNT(*) FROM {$table} {$where}";
}

// get totalCount
$cstm = $pdo->prepare($countQ);
$cstm->execute($params);
$totalCount = (int)$cstm->fetchColumn();

// main query
$sql = "{$select} FROM {$table} {$where} {$orderBy} {$limitClause} {$offsetClause}";
$stm = $pdo->prepare($sql);
$stm->execute($params);

// DEBUG: записываем сгенерированный SQL и параметры
$debugInfo = [
'sql' => $sql,
'params' => $params,
'distinctColumns' => $distinctColumns,
'input' => $input
];
file_put_contents(__DIR__.'/debug_sql.log',
date('Y-m-d H:i:s') . "\n" .
"SQL: " . $sql . "\n" .
"PARAMS: " . print_r($params, true) .
"DISTINCT: " . print_r($distinctColumns, true) .
"INPUT: " . print_r($input, true) .
"\n" . str_repeat("-", 80) . "\n\n",
FILE_APPEND
);

$items = $stm->fetchAll(PDO::FETCH_ASSOC);

// Преобразование пустых строк в null
foreach ($items as &$row) {
foreach ($row as $colName => &$value) {
// Проверяем, является ли значение пустой строкой
if (is_string($value) && trim($value) === '') {
$value = null;
}
}
}
unset($row, $value); // разрываем ссылки после foreach

/**
* === НОВАЯ ЧАСТЬ: обработка url-ов изображений ===
* Список форматов, которые мы считаем «изображением»:
* jpg, jpeg, png, webp, gif
*/
$imageExtensions = ['jpg','jpeg','png','webp','gif'];
$extPattern = implode('|', $imageExtensions);
$regexImageUrl = '/^https?:\/\/.+\.(' . $extPattern . ')$/i';

foreach ($items as &$row) {
foreach ($row as $colName => $value) {
if (is_string($value) && preg_match($regexImageUrl, $value)) {
// Заменяем строку на вложенный массив с ключами "file" и "size"
$row[$colName] = [
'file' => $value,
'size' => [1920, 1080]
];
}
}
}
unset($row); // разрываем ссылку после foreach

// columns meta с учетом комментариев
$columns = [];
if ($items) {
// Перебираем все имена столбцов на основании ключей первой строки
foreach (array_keys($items[0]) as $colName) {
// Комментарий для этого столбца (или пустая строка, если его нет)
$commentText = $colComments[$colName] ?? '';
// Для нечувствительного к регистру поиска
$commentLower = mb_strtolower($commentText);

// Определяем тип в зависимости от слов 'markdown' или 'image' в комментарии
// или если в названии столбца есть 'img' или 'image'
$colNameLower = mb_strtolower($colName);

if (strpos($commentLower, 'markdown') !== false) {
$colType = 'markdown';
} elseif (strpos($commentLower, 'image') !== false ||
strpos($colNameLower, 'img') !== false ||
strpos($colNameLower, 'photo') !== false ||
strpos($colNameLower, 'foto') !== false ||
strpos($colNameLower, 'image') !== false) {
$colType = 'image';
} else {
// Стандартный способ: string/number/boolean и т.д.
$colType = getColumnType($items[0][$colName]);
}

// Если комментарий непустой — используем его как отображаемое имя,
// иначе берём само имя столбца
$displayName = $commentText !== '' ? $commentText : $colName;

$columns[] = [
'id' => $colName,
'name' => $displayName,
'type' => $colType,
'comment' => $commentText
];
}
}

// Формируем финальный ответ
$response = [
'columns' => $columns,
'rows' => $items,
'totalCount' => $totalCount
];

// Сохраняем в кеш если нужно
if ($shouldUseCache) {
$json = json_encode($response);
file_put_contents($cacheFile, $json);
}

header('Content-Type: application/json');
echo json_encode($response);

} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
?>

Хватит читать советы «для всех»

Подписывайся — рассказываю, как маркетинг работает на практике, с цифрами и кейсами.

ПОДПИСАТЬСЯ!

Без воды, прямо и честно.

ИП Вечкасов Кирилл Александрович, ИНН: 860326713173, ОГРН: 323784700359197

Политика конфиденциальности
Офферта