ваше сообщение коммита

This commit is contained in:
2025-12-24 14:46:30 +03:00
parent c02d0a38ac
commit 37d6072cf2
11 changed files with 1956 additions and 57 deletions

View File

@@ -18,6 +18,7 @@ const path = require('path');
const multer = require('multer');
const vectorSearchClient = require('../services/vectorSearchClient');
const logger = require('../utils/logger');
const { preRenderBlog } = require('../scripts/pre-render-blog');
const FIELDS_TO_EXCLUDE = ['image', 'tags'];
@@ -42,7 +43,9 @@ async function ensureAdminPagesTable(fields) {
'id SERIAL PRIMARY KEY',
'author_address_encrypted TEXT NOT NULL', // Зашифрованный адрес автора
'created_at TIMESTAMP DEFAULT NOW()',
'updated_at TIMESTAMP DEFAULT NOW()'
'updated_at TIMESTAMP DEFAULT NOW()',
'show_in_blog BOOLEAN DEFAULT FALSE', // Показывать в блоге
'slug TEXT UNIQUE' // URL-friendly идентификатор для SEO
];
for (const field of fields) {
columns.push(`"${field}_encrypted" TEXT`);
@@ -63,6 +66,29 @@ async function ensureAdminPagesTable(fields) {
);
}
// Добавляем поле show_in_blog если его нет (не зашифрованное поле)
if (!existingCols.includes('show_in_blog')) {
await db.getQuery()(
`ALTER TABLE ${tableName} ADD COLUMN show_in_blog BOOLEAN DEFAULT FALSE`
);
}
// Добавляем поле slug если его нет
if (!existingCols.includes('slug')) {
await db.getQuery()(
`ALTER TABLE ${tableName} ADD COLUMN slug TEXT`
);
// Создаем уникальный индекс для slug
try {
await db.getQuery()(
`CREATE UNIQUE INDEX IF NOT EXISTS ${tableName}_slug_unique ON ${tableName}(slug) WHERE slug IS NOT NULL`
);
} catch (e) {
// Индекс может уже существовать, игнорируем ошибку
console.log('[pages] Индекс для slug уже существует или ошибка создания:', e.message);
}
}
for (const field of fields) {
const encryptedField = `${field}_encrypted`;
if (!existingCols.includes(encryptedField)) {
@@ -98,6 +124,87 @@ function stripHtml(html) {
return String(html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/**
* Генерирует URL-friendly slug из текста
* @param {string} text - Исходный текст
* @param {number} maxLength - Максимальная длина slug (по умолчанию 100)
* @returns {string} - Сгенерированный slug
*/
function generateSlug(text, maxLength = 100) {
if (!text) return '';
return text
.toLowerCase()
.trim()
// Транслитерация кириллицы в латиницу
.replace(/[а-яё]/g, (char) => {
const map = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
};
return map[char] || char;
})
// Заменяем все не-латинские символы и цифры на дефисы
.replace(/[^a-z0-9]+/g, '-')
// Убираем дефисы в начале и конце
.replace(/^-+|-+$/g, '')
// Ограничиваем длину
.substring(0, maxLength)
.replace(/-+$/, ''); // Убираем дефис в конце после обрезки
}
/**
* Генерирует уникальный slug, проверяя существование в БД
* @param {string} title - Заголовок страницы
* @param {number} pageId - ID страницы (для исключения при проверке уникальности)
* @param {string} tableName - Имя таблицы
* @returns {Promise<string>} - Уникальный slug
*/
async function generateUniqueSlug(title, pageId, tableName) {
let baseSlug = generateSlug(title);
if (!baseSlug) {
// Если slug пустой, используем id
baseSlug = `page-${pageId || Date.now()}`;
}
let slug = baseSlug;
let counter = 1;
// Проверяем уникальность
while (true) {
let query = `SELECT id FROM ${tableName} WHERE slug = $1`;
const params = [slug];
// Если это редактирование, исключаем текущую страницу
if (pageId) {
query += ` AND id != $2`;
params.push(pageId);
}
const result = await db.getQuery()(query, params);
if (result.rows.length === 0) {
// Slug уникален
return slug;
}
// Slug уже существует, добавляем номер
slug = `${baseSlug}-${counter}`;
counter++;
// Защита от бесконечного цикла
if (counter > 1000) {
slug = `${baseSlug}-${Date.now()}`;
break;
}
}
return slug;
}
// Создать страницу (только для админа)
router.post('/', upload.single('file'), async (req, res) => {
console.log('[pages] POST /: Начало обработки запроса на создание страницы');
@@ -202,7 +309,8 @@ router.post('/', upload.single('file'), async (req, res) => {
? (() => { const parsed = parseInt(bodyRaw.order_index); return isNaN(parsed) ? 0 : parsed; })()
: 0,
nav_path: bodyRaw.nav_path || null,
is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true'
is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true',
show_in_blog: bodyRaw.show_in_blog === true || bodyRaw.show_in_blog === 'true' || bodyRaw.show_in_blog === true
};
console.log('[pages] POST /: Создание страницы, данные:', {
@@ -213,6 +321,14 @@ router.post('/', upload.single('file'), async (req, res) => {
format: pageData.format
});
// Генерируем slug из заголовка (если не передан вручную)
let slug = bodyRaw.slug && bodyRaw.slug.trim()
? bodyRaw.slug.trim()
: await generateUniqueSlug(pageData.title, null, tableName);
// Добавляем slug в pageData
pageData.slug = slug;
// Формируем SQL для вставки данных (включаем все поля, даже null)
// Фильтруем только undefined, null значения включаем (они допустимы в БД)
const dataEntries = Object.entries(pageData).filter(([k, v]) => {
@@ -261,6 +377,23 @@ router.post('/', upload.single('file'), async (req, res) => {
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
// Автоматическая индексация при создании отключена
// Запускаем pre-rendering для блога, если страница публичная и для блога
if (created.visibility === 'public' &&
created.status === 'published' &&
created.show_in_blog &&
created.slug &&
typeof created.slug === 'string' &&
created.slug.trim() !== '') {
// Запускаем асинхронно, не блокируя ответ
preRenderBlog({
renderList: true,
renderArticles: true,
specificSlug: created.slug.trim()
}).catch(err => {
console.error('[pages] Ошибка pre-rendering при создании страницы:', err);
});
}
res.json(created);
} catch (error) {
console.error('[pages] Ошибка при создании страницы:', error);
@@ -839,6 +972,26 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
if (FIELDS_TO_EXCLUDE.includes(k)) continue;
if (k === 'required_permission') continue; // Уже обработано выше
// Обрабатываем show_in_blog как boolean
if (k === 'show_in_blog') {
updateData[k] = v === true || v === 'true' || v === 1 || v === '1';
continue;
}
// Обрабатываем slug
if (k === 'slug') {
if (v && String(v).trim()) {
// Если slug передан, проверяем уникальность
const uniqueSlug = await generateUniqueSlug(v, pageId, tableName);
updateData[k] = uniqueSlug;
} else if (incoming.title) {
// Если slug не передан, но есть title, генерируем из title
const uniqueSlug = await generateUniqueSlug(incoming.title, pageId, tableName);
updateData[k] = uniqueSlug;
}
continue;
}
// Нормализуем категорию: приводим к нижнему регистру для консистентности
if (k === 'category') {
updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null;
@@ -993,6 +1146,23 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
// Автоматическая индексация при обновлении отключена
// Запускаем pre-rendering для блога, если страница публичная и для блога
if (updated.visibility === 'public' &&
updated.status === 'published' &&
updated.show_in_blog &&
updated.slug &&
typeof updated.slug === 'string' &&
updated.slug.trim() !== '') {
// Запускаем асинхронно, не блокируя ответ
preRenderBlog({
renderList: true,
renderArticles: true,
specificSlug: updated.slug.trim()
}).catch(err => {
console.error('[pages] Ошибка pre-rendering при обновлении страницы:', err);
});
}
res.json(updated);
} catch (error) {
console.error('[pages] PATCH /:id: Ошибка при обновлении страницы:', error);
@@ -1210,6 +1380,216 @@ router.get('/public/all', async (req, res) => {
}
});
// Получить все страницы блога (только с show_in_blog = true)
router.get('/blog/all', async (req, res) => {
try {
const tableName = `admin_pages_simple`;
// Проверяем, есть ли таблица
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.json([]);
}
// Поддержка фильтрации по категории и поиску
const { category, search } = req.query;
let whereClause = `WHERE visibility = 'public' AND status = 'published' AND show_in_blog = TRUE`;
const params = [];
let paramIndex = 1;
if (category) {
whereClause += ` AND category = $${paramIndex}`;
params.push(category);
paramIndex++;
}
if (search) {
whereClause += ` AND (title ILIKE $${paramIndex} OR summary ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// Сортировка: сначала по дате создания (новые первыми), затем по order_index
const { rows } = await db.getQuery()(`
SELECT * FROM ${tableName}
${whereClause}
ORDER BY
created_at DESC,
COALESCE(order_index, 0) ASC
`, params);
console.log(`[pages] GET /blog/all: найдено ${rows.length} страниц блога`);
// Обрабатываем результаты: генерируем slug для страниц, у которых его нет
const processedRows = await Promise.all(rows.map(async (row) => {
// Если у страницы нет slug, генерируем его из title
if (!row.slug || row.slug.trim() === '') {
try {
// Получаем расшифрованный title для генерации slug
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Расшифровываем title (если он зашифрован)
let title = row.title;
if (row.title_encrypted) {
const titleResult = await db.getQuery()(
`SELECT decrypt_text($1, $2) as title`,
[row.title_encrypted, encryptionKey]
);
title = titleResult.rows[0]?.title || row.title || `page-${row.id}`;
}
// Генерируем slug
const newSlug = await generateUniqueSlug(title, row.id, tableName);
// Обновляем slug в БД
await db.getQuery()(
`UPDATE ${tableName} SET slug = $1 WHERE id = $2`,
[newSlug, row.id]
);
// Обновляем slug в объекте row
row.slug = newSlug;
console.log(`[pages] GET /blog/all: сгенерирован slug "${newSlug}" для страницы ${row.id}`);
} catch (error) {
console.error(`[pages] GET /blog/all: ошибка генерации slug для страницы ${row.id}:`, error);
// Если не удалось сгенерировать slug, используем id как fallback
row.slug = `page-${row.id}`;
}
}
return row;
}));
res.json(processedRows);
} catch (error) {
console.error('Ошибка получения страниц блога:', error);
res.status(500).json([]);
}
});
// Получить страницу блога по slug
router.get('/blog/:slug', async (req, res) => {
try {
const tableName = `admin_pages_simple`;
let slug = req.params.slug;
// Декодируем slug (на случай если он был закодирован)
try {
slug = decodeURIComponent(slug);
} catch (e) {
// Если декодирование не удалось, используем как есть
console.warn('[pages] Ошибка декодирования slug:', e.message);
}
// Валидация slug
if (!slug || typeof slug !== 'string' || slug.trim() === '') {
return res.status(400).json({ error: 'Невалидный slug' });
}
slug = slug.trim();
// Проверяем, есть ли таблица
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.status(404).json({ error: 'Страница не найдена' });
}
// Получаем страницу по slug
const { rows } = await db.getQuery()(
`SELECT * FROM ${tableName}
WHERE slug = $1
AND visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
LIMIT 1`,
[slug]
);
console.log(`[pages] GET /blog/:slug: поиск по slug "${slug}", найдено строк: ${rows.length}`);
if (rows.length === 0) {
// Пробуем найти страницу без учета регистра и пробелов
const { rows: rowsCaseInsensitive } = await db.getQuery()(
`SELECT * FROM ${tableName}
WHERE LOWER(TRIM(slug)) = LOWER(TRIM($1))
AND visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
LIMIT 1`,
[slug]
);
if (rowsCaseInsensitive.length > 0) {
console.log(`[pages] GET /blog/:slug: найдено с учетом регистра, slug в БД: "${rowsCaseInsensitive[0].slug}"`);
return res.json(rowsCaseInsensitive[0]);
}
// Показываем все доступные slug для отладки (только в dev режиме)
if (process.env.NODE_ENV !== 'production') {
const { rows: allSlugs } = await db.getQuery()(
`SELECT id, slug, title FROM ${tableName}
WHERE visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
LIMIT 10`
);
console.log(`[pages] GET /blog/:slug: доступные slug:`, allSlugs.map(r => ({ id: r.id, slug: r.slug })));
}
return res.status(404).json({ error: 'Страница не найдена' });
}
console.log(`[pages] GET /blog/:slug: страница найдена, id: ${rows[0].id}, slug: ${rows[0].slug}`);
// Расшифровываем зашифрованные поля
const page = rows[0];
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Создаем объект с расшифрованными данными
const decryptedPage = { ...page };
// Расшифровываем поля, если они зашифрованы
const fieldsToDecrypt = ['title', 'summary', 'content', 'seo', 'settings'];
for (const field of fieldsToDecrypt) {
const encryptedField = `${field}_encrypted`;
if (page[encryptedField]) {
try {
const decryptResult = await db.getQuery()(
`SELECT decrypt_text($1, $2) as ${field}`,
[page[encryptedField], encryptionKey]
);
if (decryptResult.rows[0] && decryptResult.rows[0][field] !== null) {
decryptedPage[field] = decryptResult.rows[0][field];
}
} catch (decryptError) {
console.warn(`[pages] GET /blog/:slug: ошибка расшифровки поля ${field}:`, decryptError.message);
// Если расшифровка не удалась, оставляем оригинальное значение или null
if (page[field]) {
decryptedPage[field] = page[field];
}
}
} else if (page[field]) {
// Если поле не зашифровано, используем его как есть
decryptedPage[field] = page[field];
}
}
res.json(decryptedPage);
} catch (error) {
console.error('Ошибка получения страницы блога по slug:', error);
res.status(500).json({ error: 'Ошибка получения страницы' });
}
});
// Получить иерархическую структуру всех публичных страниц
router.get('/public/structure', async (req, res) => {
try {
@@ -1562,6 +1942,7 @@ router.get('/public/robots.txt', async (req, res) => {
const robotsContent = `User-agent: *
Allow: /
Allow: /blog
Allow: /content/published
Disallow: /api/
Disallow: /ws
@@ -1569,7 +1950,7 @@ Disallow: /admin/
Disallow: /content/create
Disallow: /content/edit
Sitemap: ${baseUrl}/sitemap.xml
Sitemap: ${baseUrl}/pages/public/sitemap.xml
`;
res.setHeader('Content-Type', 'text/plain');
@@ -1593,18 +1974,6 @@ router.get('/public/sitemap.xml', async (req, res) => {
`SELECT to_regclass($1) as exists`, [tableName]
);
let pages = [];
if (existsRes.rows[0].exists) {
// Получаем все опубликованные публичные страницы
const { rows } = await db.getQuery()(`
SELECT id, title, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public'
ORDER BY created_at DESC
`);
pages = rows;
}
// Генерируем XML sitemap
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@@ -1613,6 +1982,11 @@ router.get('/public/sitemap.xml', async (req, res) => {
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${baseUrl}/blog</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>${baseUrl}/content/published</loc>
<changefreq>daily</changefreq>
@@ -1620,8 +1994,41 @@ router.get('/public/sitemap.xml', async (req, res) => {
</url>
`;
// Добавляем страницы документов
for (const page of pages) {
if (existsRes.rows[0].exists) {
// Получаем страницы блога (с show_in_blog = true)
const { rows: blogPages } = await db.getQuery()(`
SELECT id, slug, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public' AND show_in_blog = TRUE
ORDER BY created_at DESC
`);
// Добавляем страницы блога с использованием slug
for (const page of blogPages) {
const lastmod = page.updated_at || page.created_at || new Date().toISOString();
const pageUrl = page.slug
? `${baseUrl}/blog/${page.slug}`
: `${baseUrl}/blog?page=${page.id}`;
sitemap += ` <url>
<loc>${escapeXml(pageUrl)}</loc>
<lastmod>${lastmod.split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
`;
}
// Получаем остальные публичные страницы (без show_in_blog)
const { rows: otherPages } = await db.getQuery()(`
SELECT id, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public' AND (show_in_blog IS NULL OR show_in_blog = FALSE)
ORDER BY created_at DESC
`);
// Добавляем остальные публичные страницы
for (const page of otherPages) {
const lastmod = page.updated_at || page.created_at || new Date().toISOString();
const pageUrl = `${baseUrl}/content/published?page=${page.id}`;
@@ -1632,6 +2039,7 @@ router.get('/public/sitemap.xml', async (req, res) => {
<priority>0.6</priority>
</url>
`;
}
}
sitemap += `</urlset>`;
@@ -1658,4 +2066,62 @@ function escapeXml(unsafe) {
});
}
// Endpoint для ручного запуска pre-rendering блога
router.post('/blog/prerender', async (req, res) => {
try {
// Проверка прав доступа (только админ)
if (!req.session || !req.session.authenticated || !req.session.address) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ error: 'Недостаточно прав' });
}
// Парсим параметры
let { renderList = true, renderArticles = true, specificSlug = null } = req.body;
// Валидация параметров
renderList = Boolean(renderList);
renderArticles = Boolean(renderArticles);
// Валидация slug
if (specificSlug && (typeof specificSlug !== 'string' || specificSlug.trim() === '')) {
return res.status(400).json({ error: 'Невалидный slug' });
}
if (specificSlug) {
specificSlug = specificSlug.trim();
}
console.log('[pages] POST /blog/prerender: Запуск pre-rendering...', {
renderList,
renderArticles,
specificSlug: specificSlug || 'all'
});
// Запускаем pre-rendering асинхронно
preRenderBlog({
renderList,
renderArticles,
specificSlug
}).then(() => {
console.log('[pages] POST /blog/prerender: Pre-rendering завершен успешно');
}).catch(err => {
console.error('[pages] POST /blog/prerender: Ошибка pre-rendering:', err);
});
// Возвращаем ответ сразу, не дожидаясь завершения
res.json({
success: true,
message: 'Pre-rendering запущен. Проверьте логи для деталей.'
});
} catch (error) {
console.error('[pages] POST /blog/prerender: Ошибка:', error);
res.status(500).json({ error: 'Ошибка запуска pre-rendering' });
}
});
module.exports = router;